From a242f1b449461a17c642016183d58231769ba5ec Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 00:27:59 -0700 Subject: [PATCH] fix(FN-7952): migrate bundled plugins to PostgreSQL (#2111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Bundled plugins now persist shared runtime state in project-scoped PostgreSQL tables instead of maintaining independent SQLite authority. Reports, CLI Printing Press, Compound Engineering, Roadmap, Even Realities, and WhatsApp all follow the same ownership and startup contract as Fusion core. ## Design decisions - Plugin schema hooks run through the host’s PostgreSQL owner and enforce project isolation. - The SDK exposes the host contract needed by bundled plugins without importing engine internals. - Legacy Roadmap ownership fixtures use the supported empty-owner sentinel, preserving current composite primary/foreign keys while exercising backfill behavior. - The lockfile travels with the Even Realities PostgreSQL dependency so packaged installs remain reproducible. ## Validation - All six affected plugin builds pass. - Affected plugin suites pass: 773 tests across Printing Press, Compound Engineering, Even Realities, Reports, Roadmap, and WhatsApp. - `pnpm test:gate` passes all 478 gate tests. - This PR changes 40 files. ## Stack - Depends on #2110 → #2109 → #2108. - The documentation/release PR completes the stack. Related: #2105 ## Summary by CodeRabbit * **Breaking Changes** * PostgreSQL is now required for runtime storage; SQLite files are used only as one-time migration inputs. * The legacy `FUSION_NO_EMBEDDED_PG` fallback has been removed. * **New Features** * Added project-isolated PostgreSQL storage for plugins, reports, tasks, notifications, and other plugin data. * Added agent tools for reports and CLI service drafts. * Added PostgreSQL schema initialization support for plugin authors. * **Bug Fixes** * Improved migration and recovery of legacy plugin state. * Prevented cross-project data access and strengthened transactional schema updates. * **Documentation** * Updated storage, migration, deployment, plugin authoring, CLI, and dashboard guidance for PostgreSQL. --- .changeset/finish-postgres-runtime-cutover.md | 7 + .changeset/isolate-postgres-plugin-state.md | 7 + CONCEPTS.md | 4 +- README.md | 2 +- docs/PLUGIN_AUTHORING.md | 41 +- docs/README.md | 6 +- docs/architecture.md | 89 ++- docs/cli-reference.md | 12 +- docs/dashboard-guide.md | 6 +- docs/dashboard-realtime.md | 2 +- docs/multi-project-sequencing.md | 6 +- docs/multi-project.md | 41 +- docs/performance/dashboard-load.md | 2 + docs/plugin-management.md | 4 +- docs/postgres-migration-review-2026-06-26.md | 4 +- docs/postgres-migration-review-2026-07-14.md | 108 +++ docs/research.md | 20 +- docs/sandbox.md | 4 +- docs/secrets.md | 16 +- docs/settings-reference.md | 6 +- docs/storage.md | 68 +- docs/task-management.md | 12 +- docs/todo-view.md | 2 +- packages/cli/src/commands/dashboard.ts | 15 +- packages/cli/src/commands/db.ts | 1 + packages/cli/src/commands/serve.ts | 19 +- .../postgres/plugin-schema-hook.test.ts | 230 ++++++- .../__tests__/postgres/schema-applier.test.ts | 76 +++ .../postgres/sqlite-migrator.test.ts | 246 ++++++- packages/core/src/plugin-store.ts | 18 +- packages/core/src/plugin-types.ts | 7 +- .../core/src/postgres/plugin-schema-hook.ts | 618 ++++++++++++++---- packages/core/src/postgres/schema/plugin.ts | 100 +-- packages/core/src/postgres/sqlite-migrator.ts | 204 +++++- packages/core/src/postgres/startup-factory.ts | 1 + packages/dashboard/app/api/legacy.ts | 16 +- .../app/components/TaskIdIntegrityBanner.tsx | 11 +- .../components/dashboard/DashboardBanners.tsx | 2 +- .../dashboard-postgres-health.test.ts | 89 +++ .../src/dashboard-postgres-health.ts | 104 +++ packages/dashboard/src/server.ts | 122 ++-- .../src/__tests__/local-runtime.test.ts | 10 +- .../src/__tests__/local-server.test.ts | 9 +- packages/desktop/src/local-runtime.ts | 6 +- packages/desktop/src/local-server.ts | 6 +- packages/plugin-sdk/src/index.ts | 2 + .../src/__tests__/cli-press-store.pg.test.ts | 111 +++- .../__tests__/executor-runtime-env.test.ts | 61 ++ .../src/__tests__/tools.test.ts | 66 ++ .../src/index.ts | 50 +- .../src/routes/wizard-routes.ts | 12 +- .../src/runtime/executor-runtime-env.ts | 47 +- .../src/store/cli-press-store.ts | 131 +++- .../src/tools.ts | 161 +++++ .../src/__tests__/pipeline-store.pg.test.ts | 73 ++- .../src/index.ts | 3 +- .../src/schema.ts | 5 +- .../src/session/session-store.ts | 8 +- .../src/sync/pg-schema.ts | 27 +- .../src/sync/pipeline-store.ts | 70 +- .../package.json | 3 +- .../src/__tests__/index.test.ts | 14 +- .../__tests__/notification-store.pg.test.ts | 62 ++ .../src/__tests__/notification-store.test.ts | 85 +-- .../src/__tests__/notifier.test.ts | 94 +-- .../src/index.ts | 38 +- .../src/notifications/store.ts | 130 ++-- .../src/notifier.ts | 41 +- .../tsconfig.json | 3 +- plugins/fusion-plugin-reports/README.md | 2 +- .../__tests__/report-store-provider.test.ts | 36 + .../src/__tests__/report-store.pg.test.ts | 50 +- .../src/__tests__/tools.test.ts | 35 + plugins/fusion-plugin-reports/src/index.ts | 46 +- .../src/routes/report-approval-routes.ts | 26 +- .../src/routes/report-export-routes.ts | 28 +- .../src/routes/report-list-routes.ts | 24 +- .../src/store/report-store-provider.ts | 25 + .../src/store/report-store.ts | 18 +- plugins/fusion-plugin-reports/src/tools.ts | 131 ++++ .../src/__tests__/roadmap-store.pg.test.ts | 13 +- .../src/routes/roadmap-routes.ts | 6 +- .../src/__tests__/auth-state.test.ts | 188 +++--- .../src/__tests__/connection.test.ts | 97 ++- .../src/__tests__/index.test.ts | 107 +-- .../src/__tests__/persistence.pg.test.ts | 23 + .../src/auth-state.ts | 16 +- .../fusion-plugin-whatsapp-chat/src/index.ts | 38 +- .../src/persistence.ts | 151 +---- pnpm-lock.yaml | 3 + 90 files changed, 3371 insertions(+), 1368 deletions(-) create mode 100644 .changeset/finish-postgres-runtime-cutover.md create mode 100644 .changeset/isolate-postgres-plugin-state.md create mode 100644 docs/postgres-migration-review-2026-07-14.md create mode 100644 packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts create mode 100644 packages/dashboard/src/dashboard-postgres-health.ts create mode 100644 plugins/fusion-plugin-cli-printing-press/src/__tests__/executor-runtime-env.test.ts create mode 100644 plugins/fusion-plugin-cli-printing-press/src/__tests__/tools.test.ts create mode 100644 plugins/fusion-plugin-cli-printing-press/src/tools.ts create mode 100644 plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.pg.test.ts create mode 100644 plugins/fusion-plugin-reports/src/__tests__/report-store-provider.test.ts create mode 100644 plugins/fusion-plugin-reports/src/__tests__/tools.test.ts create mode 100644 plugins/fusion-plugin-reports/src/store/report-store-provider.ts create mode 100644 plugins/fusion-plugin-reports/src/tools.ts diff --git a/.changeset/finish-postgres-runtime-cutover.md b/.changeset/finish-postgres-runtime-cutover.md new file mode 100644 index 0000000000..3ac5b01d8c --- /dev/null +++ b/.changeset/finish-postgres-runtime-cutover.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": major +--- + +summary: Require PostgreSQL storage and complete runtime parity across projects, archives, missions, plugins, and maintenance. +category: breaking +dev: Remove FUSION_NO_EMBEDDED_PG and sync runtime fallbacks; legacy SQLite files remain one-time migration inputs only. diff --git a/.changeset/isolate-postgres-plugin-state.md b/.changeset/isolate-postgres-plugin-state.md new file mode 100644 index 0000000000..f9cafeef64 --- /dev/null +++ b/.changeset/isolate-postgres-plugin-state.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve and isolate bundled plugin state during the PostgreSQL cutover. +category: fix +dev: Adds project-scoped plugin schemas, legacy ownership recovery, and atomic schema-contract enforcement. diff --git a/CONCEPTS.md b/CONCEPTS.md index ac6c5a59f9..1aabaa7088 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -267,13 +267,13 @@ A horizontal row on the multi-lane board, one per workflow in use by visible car A workflow node kind expressing passive dwell — a card rests in its column until a release condition fires: manual promote, timer, downstream capacity available, dependency satisfied, or external event. Hold release is evaluated by a substrate sweep (the generalized scheduler), which reserves worktree + semaphore slots before issuing the release move. ### Split / Join -Parallel-branch node kinds. A `split` launches its outgoing edges concurrently; a `join` synchronizes them with `mode: all | any | quorum(n)` and `onBranchFailure: fail-fast | collect`. During the parallel window the card stays in the split's column (its board position never forks); on join resolution it advances to the join's column. `execute`/`merge` seam nodes are forbidden inside branches (one worktree/session per task; merge is exclusive). Per-branch run state persists in SQLite so a crashed branch resumes where it died. +Parallel-branch node kinds. A `split` launches its outgoing edges concurrently; a `join` synchronizes them with `mode: all | any | quorum(n)` and `onBranchFailure: fail-fast | collect`. During the parallel window the card stays in the split's column (its board position never forks); on join resolution it advances to the join's column. `execute`/`merge` seam nodes are forbidden inside branches (one worktree/session per task; merge is exclusive). Per-branch run state persists in PostgreSQL so a crashed branch resumes where it died. ### Default workflow The built-in workflow (`builtin:coding`) that reproduces the legacy pipeline verbatim: six columns whose ids equal the legacy enum values, with traits matching legacy semantics (`triage`=intake, `todo`=hold+reset-on-entry, `in-progress`=wip+abort-on-exit+timing, `in-review`=merge-blocker+stall-detection+merge, `done`=complete, `archived`=archived). A null workflow selection resolves to it at read time. Non-editable, non-deletable. ### transitionPending -A persisted crash-safe marker (`tasks.transitionPending`) written in the same transaction as a column change, recording the post-commit hooks (`hooksRemaining`) that still owe idempotent execution. Cleared once they complete. Recovery reads it exclusively from SQLite (the authoritative store); a crash mid-transition re-runs the idempotent hooks. A throwing or missing hook degrades (audit) and clears its entry — it never strands the card or wedges the task lock. +A persisted crash-safe marker (`tasks.transitionPending`) written in the same PostgreSQL transaction as a column change, recording the post-commit hooks (`hooksRemaining`) that still owe idempotent execution. Cleared once they complete. Recovery reads it from the authoritative PostgreSQL task row; a crash mid-transition re-runs the idempotent hooks. A throwing or missing hook degrades (audit) and clears its entry — it never strands the card or wedges the task lock. ## Step inversion diff --git a/README.md b/README.md index e63d72a331..e7f5add91d 100644 --- a/README.md +++ b/README.md @@ -642,7 +642,7 @@ fn skills install firebase/agent-skills # Install agent skills | Package | Description | |---------|-------------| -| `@fusion/core` | Domain model — tasks, board columns, SQLite store | +| `@fusion/core` | Domain model — tasks, board columns, PostgreSQL stores | | `@fusion/dashboard` | Web UI — Express server + kanban board with SSE | | `@fusion/engine` | AI engine — planning, execution, scheduling, workflow steps | | `@runfusion/fusion` | CLI + extension — published to npm | diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index ee1022de9f..d02aadc07a 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -347,7 +347,8 @@ const plugin: FusionPlugin = { | `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise \| void` | Task moved between columns | | `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise \| void` | Task reached "done" | | `onError` | `(error: Error, ctx: PluginContext) => Promise \| void` | Error occurred in plugin execution | -| `onSchemaInit` | `(db: Database) => Promise \| void` | After enabled plugins are loaded at startup (engine/daemon/dashboard/serve) | +| `onPostgresSchemaInit` | `() => PluginPostgresSchemaDefinition` | Before `onLoad`; Fusion validates and applies the declarative plan with a short-lived migration connection | +| `onSchemaInit` (legacy) | `(db: Database) => Promise \| void` | SQLite-only compatibility declaration; unsupported for third-party plugins in the PostgreSQL runtime | | `executorRuntimeEnv` | `(taskCtx: ExecutorRuntimeTaskContext, ctx: PluginContext) => Promise \| ExecutorRuntimeEnvContribution` | Before executor-spawned task commands run, to contribute task-scoped env and PATH prepends | ### Hook Behavior @@ -356,26 +357,32 @@ const plugin: FusionPlugin = { - **Timeout**: 5 seconds per invocation (logged and skipped if exceeded) - **Error Isolation**: Hook failures never block other hooks or abort startup - **Optional**: Only define the hooks you need -- **Schema hook execution**: `onSchemaInit` hooks run sequentially in plugin dependency order (from `resolveLoadOrder`) after `loadAllPlugins()`. -- **Schema hook database API**: The hook receives the runtime `Database` instance, including `db.exec()` and `db.prepare()` for SQL DDL. -- **Schema hook constraints**: `onSchemaInit` is intended for idempotent DDL only (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Avoid data backfills or long-running logic. -- **Bundled plugin pattern**: Keep DDL in a plugin-local schema module (for example `src/-schema.ts`) and call it from `hooks.onSchemaInit` so schema ownership stays with the plugin package instead of `@fusion/core` bootstrap SQL. +- **Schema preflight**: `onPostgresSchemaInit` is evaluated and validated before the plugin is marked started or `onLoad` runs. An invalid or SQLite-only third-party schema fails without leaving `onLoad` subscriptions or timers behind. +- **No privileged handle**: The hook returns data and receives no database object. Fusion alone opens the short-lived migration connection; ordinary plugin hooks and routes continue to use the project-bound forced-RLS runtime role. +- **Allowed DDL**: Return one idempotent `CREATE TABLE IF NOT EXISTS`, `CREATE [UNIQUE] INDEX IF NOT EXISTS`, or `ALTER TABLE` statement per array item. Every object must live in the `project` schema and every referenced table must begin with the declared `tablePrefix`. Semicolon-separated batches and data-changing/admin statements are rejected. +- **Required ownership**: Every created table declares `project_id text NOT NULL` and a `project_id`-leading composite primary key. Fusion installs the column default, ownership trigger, forced RLS policy, and runtime grants after creation. Composite foreign keys should include `project_id` on both sides. +- **Schema evolution**: Increment `version` when the declarative plan changes. Statements remain idempotent and must safely rerun during restart or hot reload. Avoid data backfills or long-running logic. +- **Legacy cutover**: Third-party `onSchemaInit(Database)` hooks are no longer executable in the PostgreSQL runtime. Bundled plugins retain host-owned PostgreSQL equivalents during migration, but external plugins must provide `onPostgresSchemaInit`. ### Example: Schema initialization hook ```typescript hooks: { - onSchemaInit: async (db) => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_roadmaps ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - created_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_plugin_roadmaps_created_at - ON plugin_roadmaps(created_at); - `); - }, + onPostgresSchemaInit: () => ({ + version: 1, + tablePrefix: "acme_", + statements: [ + `CREATE TABLE IF NOT EXISTS project.acme_roadmaps ( + project_id text NOT NULL, + id text NOT NULL, + title text NOT NULL, + created_at text NOT NULL, + PRIMARY KEY (project_id, id) + )`, + `CREATE INDEX IF NOT EXISTS idx_acme_roadmaps_created_at + ON project.acme_roadmaps(project_id, created_at)`, + ], + }), }, ``` @@ -1336,7 +1343,7 @@ Polls CI status for branches and provides custom API endpoints. Standalone roadmap planning plugin extracted from dashboard host code. -- Demonstrates: `hooks.onSchemaInit` for plugin-owned schema DDL (`ensureRoadmapSchema`) +- Demonstrates: the bundled legacy `hooks.onSchemaInit` plus its host-owned PostgreSQL schema during the cutover; new external plugins use `hooks.onPostgresSchemaInit` - Demonstrates: plugin-scoped route namespace under `/api/plugins/fusion-plugin-roadmap/*` - Demonstrates: top-level navigation registration through `dashboardViews` (`viewId: "roadmaps"`) and host static view registration - Demonstrates: AI suggestion flows that consume `ctx.createAiSession` through plugin route handlers diff --git a/docs/README.md b/docs/README.md index bc66ff24df..505ccbc074 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,7 +58,7 @@ Planner oversight (FN-7508 → FN-7583) is fully documented in Settings Referenc | [Architecture](./architecture.md) | System architecture, package layout, storage model, and engine execution flow | | [Secrets Store (`SecretsStore`)](./architecture.md#secrets-store-secretsstore) | Core encrypted secret subsystem overview: scopes, AES-256-GCM at-rest model, policy semantics, and public store API surface | | [Dashboard Real-Time](./dashboard-realtime.md) | Canonical event-stream architecture contract (shared `/api/events` bus + dedicated stream boundaries), with project/node scoping, reconnect/cleanup behavior, and realtime pitfalls | -| [Storage](./storage.md) | Storage architecture, migration, archive system, and SQLite schema | +| [Storage](./storage.md) | PostgreSQL runtime storage, archive, migration compatibility, and file-backed payloads | | [DAG Architecture Deliverables](./dag/) | Milestone A DAG architecture documents plus Milestone B prototype scaffold docs (schema migration plan, DagCoordinator design, implementation checklist) | | [Dev Server Module Audit](./dev-server-modules.md) | Analysis of parallel dashboard dev-server module families, production wiring, and consolidation guidance | | [Shared Mesh Replication Protocol](./shared-mesh-protocol.md) | Canonical multi-leader replication/write-coordination contract (versioning, quorum, leases/fencing, queue/replay, reconciliation, and degraded-read semantics) | @@ -116,7 +116,7 @@ FN-7088 links previously-unlinked first-class testing and baseline docs here so | [Dev Server Module Boundary Audit](./dev-server-module-boundary-audit.md) | Boundary/ownership audit for parallel `dev-server-*` vs `devserver-*` dashboard modules and FN-2212 prioritization guidance | | [spawn_agent Approval Evaluation (FN-3973)](./spawn-agent-approval-evaluation.md) | Decision to keep fn_spawn_agent under generic action-gate governance rather than durable agent provisioning policy | | [Task Lineage Reconciliation Notes](./task-lineage-reconciliation.md) | Historical task-ID reuse patterns, confidence semantics for commit attribution, and reconciliation methodology (FN-3953, FN-3998) | -| [Dashboard Load Performance](./performance/dashboard-load.md) | SQLite index analysis and optimization for dashboard boot path queries | +| [Dashboard Load Performance (historical)](./performance/dashboard-load.md) | Pre-cutover SQLite index analysis retained for performance archaeology | | [CLI Printing Press Plugin Design](./design/cli-printing-press-plugin.md) | Architecture design for the CLI printing press bundled plugin (FN-3762) | | [CLI Printing Press Research](./research/cli-printing-press.md) | Upstream `cli-printing-press` analysis and Fusion integration mapping (FN-3761) | | [Research vs Experiment Session Naming Decision](./research/naming-decision-2026-05.md) | Naming decision record: hybrid approach retaining `research_*` for cited-search/synthesis and adding `experiment_session_*` for upstream parity (FN-4223) | @@ -142,6 +142,8 @@ FN-7088 links previously-unlinked first-class testing and baseline docs here so | [Lost-Work Tasks Incident (2026-05-23)](./incidents/2026-05-23-lost-work-tasks.md) | Incident catalog of 9 lost-work tasks from no-op finalize and reuse-handoff bugs | | [GitLab Parity Inventory (FN-7421)](./gitlab-parity-inventory.md) | Implementation map for first-class GitLab support: import, linked issue tracking, comments, auth/settings UI, CLI/extension, and Command Center surfaces to mirror or explicitly exclude | +| [PostgreSQL Runtime Cutover Review (2026-07-14)](./postgres-migration-review-2026-07-14.md) | Current end-to-end authority inventory, intentional legacy SQLite readers, deployment contract, and verification record | +| [SQLite → PostgreSQL Migration Review (2026-06-26, historical)](./postgres-migration-review-2026-06-26.md) | Historical multi-agent review of the incomplete migration branch and its original findings | | [Dashboard Theme & UI Plugin System Proposal (2026-07-01)](./proposals/2026-07-01-dashboard-theme-plugin-system.md) | Feasibility-spike proposal for a controlled dashboard theme/UI shell extension point sharing one backend source of truth | ## External Resources diff --git a/docs/architecture.md b/docs/architecture.md index 7bab82d6ca..d5aa8a79ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,9 +105,9 @@ Cross-package automated tests now lock: │ ┌────────────────▼────────────────┐ │ Persistence │ - │ - .fusion/fusion.db (SQLite/WAL) - │ - .fusion/tasks/* (PROMPT/logs) - │ - ~/.fusion/fusion-central.db │ + │ - PostgreSQL schemas │ + │ - .fusion/tasks/* (artifacts) │ + │ - .fusion/project.json (identity)│ └──────────────────────────────────┘ ``` @@ -117,7 +117,7 @@ Cross-package automated tests now lock: | Package | Published | Role | Key files | |---|---|---|---| -| `@fusion/core` | Private | Domain model, stores, SQLite adapters, settings, shared types | `packages/core/src/types.ts`, `store.ts`, `db.ts`, `central-core.ts`, `agent-store.ts` | +| `@fusion/core` | Private | Domain model, PostgreSQL stores/adapters, settings, shared types, and legacy import tooling | `packages/core/src/types.ts`, `store.ts`, `postgres/`, `central-core.ts`, `agent-store.ts` | | `@fusion/engine` | Private | AI orchestration runtime (planning, scheduler, executor, merger, recovery) | planning processor, `scheduler.ts`, `executor.ts`, `merger.ts`, `project-runtime.ts` | | `@fusion/dashboard` | Private | Express API server + React app | `packages/dashboard/src/server.ts`, `routes.ts`, `sse.ts`, `websocket.ts`, `packages/dashboard/app/App.tsx` | | `@runfusion/fusion` | **Published** | CLI binary (`fn`) + Pi extension | `packages/cli/src/bin.ts`, `commands/*`, `project-resolver.ts`, `extension.ts` | @@ -170,23 +170,22 @@ Concrete references: - **TaskStore**: `packages/core/src/store.ts` - Main task CRUD + lifecycle store - Emits board events (`task:created`, `task:moved`, `task:updated`, ...) - - Hybrid model: SQLite metadata + filesystem blobs under `.fusion/tasks/{id}` -- **Database adapter**: `packages/core/src/db.ts` - - SQLite (`node:sqlite`) with WAL mode + foreign keys - - JSON helpers: `toJson`, `toJsonNullable`, `fromJson` - - Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), goals table (`goals`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta` - - Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage` + - Hybrid model: PostgreSQL metadata + filesystem artifacts under `.fusion/tasks/{id}` +- **Database adapter**: `packages/core/src/postgres/` + - Drizzle-backed PostgreSQL connection and async data layers + - Project-scoped rows carry `projectId`; central/plugin schemas hold shared control-plane state + - `packages/core/src/postgres/sqlite-migrator.ts` and SQLite adapters are legacy import tooling, not runtime authority - `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`; deletion is final within a bounded tombstone window that blocks a straggling post-delete write from resurrecting the row (FN-7949) — see `docs/storage.md` "AI session delete tombstones" - **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, persistence, routes, and dashboard UI live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`, plugin id `fusion-plugin-roadmap`) rather than dashboard/core ownership. - **CentralCore**: `packages/core/src/central-core.ts` - Global project registry, health, central activity feed, global concurrency - - Backed by `packages/core/src/central-db.ts` (`~/.fusion/fusion-central.db`) + - Backed by the PostgreSQL `central` schema through `AsyncCentralDatabase` - **Specialized stores**: - `AgentStore` (`agent-store.ts`) — filesystem-based agent metadata + heartbeat run history - `MissionStore` (`mission-store.ts`) — mission/milestone/slice/feature hierarchy - `GoalStore` (`goal-store.ts`) — strategic goal CRUD with server-enforced 5-active-goal cap - `AutomationStore` (`automation-store.ts`) — scheduled jobs with global/project scope isolation - - `MessageStore` (`message-store.ts`) — SQLite-backed mailbox/inbox/outbox messaging + - `MessageStore` (`message-store.ts`) — PostgreSQL-backed mailbox/inbox/outbox messaging - `ApprovalRequestStore` (`approval-request-store.ts`) — durable approval request lifecycle + append-only audit events - `ChatStore` (`chat-store.ts`) — session/message persistence for agent chat - `InsightStore` (`insight-store.ts`) — project insight persistence + dedupe/run tracking @@ -241,8 +240,8 @@ Lifecycle contract (`types.ts` `isValidApprovalRequestTransition`): `SecretsStore` (`packages/core/src/secrets-store.ts`) provides encrypted key-value secret persistence for tasks/agents (FN-4791). It is designed so plaintext values are only available at explicit reveal time and are never persisted or logged in plaintext. Scope model: -- `project` scope stores rows in `secrets` inside `.fusion/fusion.db` (project database, FN-4788). -- `global` scope stores rows in `secrets_global` inside `~/.fusion/fusion-central.db` (central database, FN-4788). +- `project` scope stores rows in PostgreSQL project-scoped `secrets` storage (FN-4788). +- `global` scope stores rows in PostgreSQL central `secrets_global` storage (FN-4788). Encryption model: - Uses `createSecretCipher` from `packages/core/src/secrets-crypto.ts` (FN-4790). @@ -409,9 +408,9 @@ Hybrid evaluator pipeline (FN-3389/FN-3391): ### Plugin System - `PluginStore` (`plugin-store.ts`) is a facade over two persistence scopes: - - **Global install metadata** in central DB table `plugin_installs` (`~/.fusion/fusion-central.db`) including manifest/path/settings/schema/dependencies + - **Global install metadata** in PostgreSQL table `central.plugin_installs`, including manifest/path/settings/schema/dependencies - **Per-project runtime state** in central DB table `project_plugin_states` keyed by normalized project path (`enabled`, `state`, `error`) -- Legacy project-local `plugins` rows in `.fusion/fusion.db` are migrated lazily on plugin-store init/read; migration is idempotent and keeps newest `updatedAt` install metadata as global canonical data while preserving per-project enablement rows +- Legacy project-local `plugins` rows in `.fusion/fusion.db` are one-time migration input; migration is idempotent and keeps newest `updatedAt` install metadata as global canonical data while preserving per-project enablement rows - Post-FN-3722, the project-local `plugins` table is legacy read-only migration input; any new install writer targeting it is a bug - `TaskStore.getPluginStore()` now propagates the configured `globalSettingsDir`/central directory so all CLI and dashboard install paths resolve the same central DB - `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules using the effective per-project plugin state @@ -678,11 +677,11 @@ Runtime action-gate flow (v1): - `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification - Durable agent error recovery (FN-7835/FN-7844/FN-7859/FN-7878/FN-7884): a heartbeat-managed, runtime-enabled non-ephemeral agent that lands in `state:"error"` remains timer-eligible and clears `lastError` by transitioning `error → active` at the next heartbeat run entry when `lastError` is recoverable. Generic/unknown errors are recoverable by default; immediate `error-unrecoverable` parking is reserved for operator-actionable auth/model/billing/quota failures, while stale worktree/module-resolution errors stay on their dedicated self-healing suppression/rebuild path. Recovery is bounded by one shared `heartbeatErrorRecovery` attempt budget (`MAX_HEARTBEAT_ERROR_RECOVERY_ATTEMPTS`, settings-overridable through the engine's optional cast-based knob) across both the timer path and `SelfHealingManager.recoverOrphanedAgents()`. Self-healing is the stale-agent backstop and still stores `durableErrorRecovery` cooldown/stale-module metadata, but it writes/reads the shared heartbeat counter and emits the same `agent:auto-recover-error-state` / `agent:error-retry-exhausted` audit surface with `source:"self-healing"`. The sweep flips `error → active` before `restartDurableAgentHeartbeat()` calls `executeHeartbeat()`, preventing run-entry recovery from re-counting or double-emitting for the same recovery. Success resets the shared counter and clears legacy sweep retry state; budget exhaustion parks the agent `paused` with `pauseReason:"error-retry-exhausted"`. On engine startup, `SelfHealingManager.resetDurableAgentErrorStateOnStartup()` runs before the steady-state sweep and treats restart as an explicit operator retry: eligible `error` and `error-retry-exhausted` durable agents have shared/legacy retry metadata reset, `lastError` and the exhaustion pause cleared, state set to `active`, heartbeat re-armed, and `agent:reset-error-state-on-startup` emitted without applying the sweep's staleness/cooldown/exhaustion gates. Non-recoverable durable heartbeat errors are not restarted; timer, startup, and sweep paths preserve exclusions for disabled runtime agents, ephemeral agents, active executions, user pauses, `error-unrecoverable` parks, operator-actionable errors, and stale worktree/module-resolution suppression. - `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions - - Batch 1 maintenance now includes `reconcile-orphaned-task-dirs` (FN-6783), a paused-safe housekeeping step that calls `TaskStore.reconcileOrphanedTaskDirs()` so valid live `.fusion/tasks/{ID}/task.json` records missing from the SQLite index become visible without waiting for process restart. The store-level guard skips any ID already present in active, soft-deleted, archived, or tombstoned storage and emits `task:reconcile-orphaned-task-dir` only for recovered rows. + - Batch 1 maintenance includes `reconcile-orphaned-task-dirs` (FN-6783), a paused-safe housekeeping step that calls `TaskStore.reconcileOrphanedTaskDirs()` so valid live `.fusion/tasks/{ID}/task.json` records missing from PostgreSQL become visible without waiting for process restart. The guard skips any ID already present in active, soft-deleted, archived, or tombstoned storage and emits `task:reconcile-orphaned-task-dir` only for recovered rows. - Batch 1 maintenance also includes `reconcile-phantom-committed-reservations` (FN-7069), which calls `TaskStore.reconcilePhantomCommittedReservations()` for committed task-ID reservations that have no live/soft-deleted/archived task row and no `.fusion/tasks/{ID}/task.json`. The sweep prunes orphaned `activityLog` rows and `agents`/cascaded `agentRuns`, preserves `runAuditEvents`, and keeps the reservation `committed` per FN-5105 so the ID is permanently reserved rather than resurrected or handed out again. - Batch 1 maintenance now includes one `fts-maintenance` step for both search indexes. The live `tasks_fts` branch still runs `merge` every tick, `optimize` every 4th tick, and `rebuild` above `32 MiB` or `1 MiB × live task count`. The archive `archived_tasks_fts` branch is lighter because archive writes are mostly append-only: `merge` every 8th tick, `optimize` every 24th tick, and `rebuild` above `64 MiB` or `512 KiB × archived row count`. Each branch is independently guarded by `fts5Available` and emits `task:fts-maintenance` run-audit telemetry with distinct `target` values (`tasks_fts` vs `archived_tasks_fts`). - AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `/.ai-merge/`, as `fusion-ai-merge-fn--` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. After `git worktree add` and before the merge/review loop, `runAiMerge` bootstraps the clean room with the shared merge dependency-sync helper: a configured `worktreeInitCommand` is authoritative and always runs, while unset settings infer `pnpm`/`npm`/`yarn`/`bun` installs from lockfiles and can skip only when the `node_modules/.fusion-install-marker` hash still matches. Failures and aborts hard-stop the AI merge before merge agents or verification run, and `merge:ai-deps-sync` records the command, skip state, and duration. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed. - - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. + - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example PostgreSQL availability/transaction errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. - `recoverPausedAbortFailures()` clears executor pause/resume abort parks only when the durable row is safe to recover. `todo`/`in-progress` rows are requeued for normal scheduling, while clean `in-review` rows (completed steps, not paused/user-paused/executing, auto-merge eligible, no confirmed or terminal merge evidence) have `status`/`error` cleared in place so review progression can continue. FN-7749 adds the manual-hold exception to the prior `autoMerge:false` guard: a benign hard-cancel pause/resume abort at a merge-region/manual-hold node is the healthy Merge & Close resting state, so already-parked rows of that exact shape are cleared in place without moving backward (FN-5147-compliant). User hard-cancel, global/user pause, terminal merge, live-execution, and other `autoMerge:false` guards remain operator-actionable. Successful recovery emits `task:auto-recover-paused-abort-park` with `preservedInReview` metadata. @@ -813,8 +812,8 @@ A lease is recoverable only when there is **no active local executor session for - Defines protocol versioning, write classes, quorum/ack semantics, lease epochs/fencing, offline queue/replay, reconciliation outcomes, restart recovery hooks, and degraded-read staleness metadata. - Existing `/api/mesh/sync` and settings-sync payloads remain the active exchange primitives while follow-on runtime tasks implement full v1 coordinator/quorum behavior. - Distributed task-ID allocation (`packages/core/src/distributed-task-id.ts`) is the first mesh-aware coordinated write primitive. - - Durable state lives in SQLite tables `distributed_task_id_state` (prefix sequence + authoritative committed count) and `distributed_task_id_reservations` (reservation lifecycle rows). - - Reserve/commit/abort execute under a process-local lock and a single SQLite transaction. Lazy reservation expiry cleanup runs inside those same transactions. `TaskStore` also uses a non-locking commit core inside its own `BEGIN IMMEDIATE` create transaction so the reservation `committed` flip and authoritative `tasks` row insert share one SQLite durability point. + - Durable state lives in PostgreSQL tables `distributed_task_id_state` (prefix sequence + authoritative committed count) and `distributed_task_id_reservations` (reservation lifecycle rows). + - Reserve/commit/abort execute through the async allocator inside PostgreSQL transactions. Lazy reservation expiry cleanup runs in the same transaction model, and task creation commits the reservation flip with the authoritative `project.tasks` insert so both share one durability point. - Default reservation TTL is `15 * 60 * 1000` ms (15 minutes). Expired/aborted reservations are **burned IDs** and are never reissued. If a post-insert create step fails after the reservation was committed (for example `task.json`/`PROMPT.md` disk materialization, file-scope validation, or duplicate-intake tombstone checks), the failed-create rollback deletes the just-created task row/partial directory, moves the reservation to `aborted`, recomputes committed reservation counters, and emits `task:reservation-commit-rolled-back`; the sequence stays burned for FN-5105 ID permanence. - `committedClusterTaskCount` from allocator state is the only authoritative cluster-wide committed-task count. Local task-row counts and ID suffix math are not authoritative. - Store open reconciles every known prefix in `distributed_task_id_state` to `max(current nextSequence, max(tasks suffix)+1, max(archivedTasks suffix)+1, max(reservation sequence)+1)`. This self-heals stale counters before ordinary task creation resumes. @@ -823,7 +822,7 @@ A lease is recoverable only when there is **no active local executor session for - Ordinary local task creation (`TaskStore.createTask()`, duplicate, and refine flows) now allocates IDs through the same distributed reserve/commit/abort lifecycle owned by `TaskStore`; the invariant is `distributed_task_id_reservations.status = 'committed'` iff a live durable `tasks` row and task directory landed for that ID. `applyReplicatedTaskCreate(...)` remains a direct reserved-ID apply path and does not require a local reservation row. - `POST /api/tasks` uses the store-owned allocator path for local creates rather than maintaining a separate route-local allocator implementation. - `POST /api/tasks` reserves a distributed ID, creates the authoritative local task with that reserved ID, then POSTs authenticated replication payloads to peer nodes. - - All create-class writes now use conflict-raising inserts, not SQLite `ON CONFLICT ... DO UPDATE`. Existing task rows and `.fusion/tasks/{id}` contents always win over stale counters or colliding reservations. + - All create-class writes use conflict-raising inserts, not upserts. Existing PostgreSQL task rows and `.fusion/tasks/{id}` contents always win over stale counters or colliding reservations. - Local create paths perform a final active+archived existence check immediately before insert. If a reserved `FN-*` still collides, the reservation is aborted/burned and the create fails loudly instead of rewriting the existing task. - Creation self-heals stale overlap state at the route layer: if a reserved `FN-*` collides with an existing task (`Task ID already exists...` or replicated-create collision), the route aborts that reservation, cleans up partial local state, reserves the next ID, and retries up to a bounded limit. - Replica apply uses `TaskStore.applyReplicatedTaskCreate(...)`, which is idempotent by task ID: replaying the same payload returns the existing task without creating duplicates. @@ -1022,11 +1021,11 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to ### Health and monitoring endpoints - **Health check**: `GET /api/health` - Returns liveness status for load balancers and monitoring - - Response: `{ status: "ok" | "degraded", version: string, uptime: number, database: { healthy: boolean, corruptionDetected: boolean, corruptionErrors: string[], isRunning: boolean, lastCheckedAt: string | null }, taskIdIntegrity: { status: "ok" | "anomaly", checkedAt: string | null, anomalies: [...], recommendedAction: string | null } }` - - Startup does not block on full `PRAGMA integrity_check(100)`; Fusion schedules it in the background shortly after boot. - - Background integrity checks are deduplicated process-wide per on-disk SQLite path: multiple `Database` instances sharing the same `fusion.db` join one shared run, and each instance still updates the underlying integrity state (`integrityCheckPending`, `integrityCheckLastRunAt`, `corruptionDetected`, `integrityCheckErrors`) that maps to `database.isRunning`, `database.lastCheckedAt`, `database.healthy`, `database.corruptionDetected`, and `database.corruptionErrors`. - - Self-healing watches `store.getDatabaseHealth()` during maintenance. Each fresh corruption detection emits a `task:auto-db-corruption-detected` run-audit database event and attempts a `db-corruption-detected` notification through the active notification service (or the ntfy fallback) with a one-hour cooldown between repeats until the health state clears. - - `POST /api/health/refresh` recomputes the task-ID integrity section on demand and returns the same top-level shape, including the current database corruption fields. + - Response: `{ status: "ok" | "degraded", version: string, uptime: number, database: { healthy: boolean, corruptionDetected: boolean, corruptionErrors: string[], isRunning: boolean, lastCheckedAt: string | null }, taskIdIntegrity: { status: "ok" | "anomaly" | "error", checkedAt: string | null, anomalies: [...], error?: string, recommendedAction: string | null } }` + - PostgreSQL startup and explicit refreshes never open or inspect a SQLite file. The server derives the live async layer from its `TaskStore`; a missing layer fails health closed instead of falling back to a synchronous healthy sentinel. + - The compatibility-shaped `database.corruptionDetected` and `database.corruptionErrors` fields carry PostgreSQL connectivity and health-query failures so existing dashboard clients receive an actionable degraded response. + - The former background `PRAGMA integrity_check` scheduling, per-`fusion.db` deduplication, and SQLite corruption notification behavior are pre-cutover history only; they are not a runtime fallback. + - `POST /api/health/refresh` recomputes PostgreSQL connectivity and task-ID integrity on demand. Detector failures return `taskIdIntegrity.status: "error"` and degrade the top-level status; they are never rewritten as an empty healthy report. - No authentication required ### Custom Provider endpoints @@ -1118,7 +1117,7 @@ The run-audit system records every mutation performed by the engine across four - **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution. - **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list. - **Database / `task:reconcile-in-review-unmet-dependencies`** — emitted by `reconcileInReviewUnmetDependencies()` (FN-6793/FN-6797) when self-healing rebounds an `in-review` task to blocked `todo` because one or more declared dependencies are still unmet. Metadata includes `unmetDeps`, `blockedBy`, and prior review status; the `-no-action` companion is emitted when task pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation prevents the backward move. -- **Database / `task:reconcile-orphaned-task-dir`** — emitted by `TaskStore.reconcileOrphanedTaskDirs()` (FN-6783) when store open or self-healing Batch 1 re-imports a valid live `.fusion/tasks/{ID}/task.json` directory with no SQLite task row anywhere. Metadata includes the recovered ID, column, status, and task JSON path. +- **Database / `task:reconcile-orphaned-task-dir`** — emitted by `TaskStore.reconcileOrphanedTaskDirs()` (FN-6783) when store open or self-healing Batch 1 re-imports a valid live `.fusion/tasks/{ID}/task.json` directory with no PostgreSQL task row anywhere. Metadata includes the recovered ID, column, status, and task JSON path. - **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`, `task:reconcile-dependency-blocking-lease-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition. - **Filesystem** — file:write, prompt:write, attachment:create, etc. - **Sandbox** — backend lifecycle events from `SandboxBackend` wiring in executor/merger/routine-runner (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) introduced after FN-4636. @@ -1174,20 +1173,18 @@ For scheduler concurrency diagnostics, the queued reason now names the active li Fusion uses a hybrid storage model. ### Per-project storage -- **SQLite DB**: `.fusion/fusion.db` +- **PostgreSQL**: project-scoped rows in the `project` schema +- **Identity marker**: `.fusion/project.json` - **Filesystem blobs** (task-local artifacts): - `.fusion/tasks/{TASK_ID}/PROMPT.md` - `.fusion/tasks/{TASK_ID}/agent.log` - `.fusion/tasks/{TASK_ID}/attachments/*` -SQLite schema is initialized in `packages/core/src/db.ts` and uses: -- WAL mode (`PRAGMA journal_mode = WAL`) -- Foreign keys (`PRAGMA foreign_keys = ON`) -- `__meta.lastModified` for change detection/polling +PostgreSQL schema is initialized by `packages/core/src/postgres/schema-applier.ts`; project isolation is enforced by `projectId` keys and async data-layer binding. ### Central storage (multi-project) -- **Central DB**: `~/.fusion/fusion-central.db` -- Schema in `packages/core/src/central-db.ts` +- **PostgreSQL central schema** +- Schema in `packages/core/src/postgres/schema/central.ts` - `projects`, `projectHealth`, `centralActivityLog`, `globalConcurrency`, `nodes`, `peerNodes`, `projectNodePathMappings`, `settingsSyncState`, `__meta` - `projectHealth.inFlightAgentCount` and `globalConcurrency.currentlyActive` are persisted slot/health bookkeeping fields. They are not live read-layer running-agent counts; dashboard and CLI read surfaces derive current running agents from tasks in `column === "in-progress"` while leaving slot acquire/free semantics and DB column names unchanged. @@ -1202,15 +1199,15 @@ SQLite schema is initialized in `packages/core/src/db.ts` and uses: Some data remains intentionally filesystem-based: - Agent instruction bundles and heartbeat markdown: `.fusion/agents/*` (`AgentStore`) -Agent/message/approval metadata and history now persist in SQLite tables. +Agent/message/approval metadata and history persist in PostgreSQL. -### Migration from legacy file storage -- Detection + migration: `packages/core/src/db-migrate.ts` -- Migrates legacy task/config/log/archive/automation/agent data into SQLite -- Creates `.bak` backups (for example `task.json.bak`, `config.json.bak`, `archive.jsonl.bak`) +### Migration from legacy SQLite/file storage +- Detection + migration: `packages/core/src/postgres/sqlite-migrator.ts` and startup-factory migration helpers +- Imports legacy `fusion.db`, `archive.db`, and file records into PostgreSQL once +- Legacy databases/backups remain recovery input and are never runtime write targets ### Archive system -- Archived task snapshots are stored in SQLite `archivedTasks` +- Archived task snapshots are stored in PostgreSQL cold-storage tables - `TaskStore` archive helpers: - `archiveTaskAndCleanup()` - `cleanupArchivedTasks()` @@ -1295,7 +1292,7 @@ Tune sensitivity by adjusting the exported constants in `stalled-review-detector **Engine as substrate, workflows as policy.** The flag inverts the architecture: the engine becomes a **capability substrate** (worktree/git/session mechanics, persistence, crash recovery, audit, machine resource ceilings — non-configurable) and **workflows carry the operating logic** as composable column traits. The mechanism/policy line (KTD-4): -- **Substrate (engine-owned, never workflow-configurable):** `AgentSemaphore`, checkout leases, worktree/git/session ops, SQLite + WAL, the crash-recovery machinery, the audit trail, the global max-sessions cap, and the three non-configurable lost-work merge guards (no sibling `fusion/fn-*` target, line-anchored attribution, no `modifiedFiles` clear on a no-op finalize). +- **Substrate (engine-owned, never workflow-configurable):** `AgentSemaphore`, checkout leases, worktree/git/session ops, PostgreSQL transactions, the crash-recovery machinery, the audit trail, the global max-sessions cap, and the three non-configurable lost-work merge guards (no sibling `fusion/fn-*` target, line-anchored attribution, no `modifiedFiles` clear on a no-op finalize). - **Policy (workflow/trait-owned):** transition validity, WIP/capacity, hold/release, drag meaning, retries, merge strategy, squash posture, file-scope enforcement mode. **Transition authority.** `moveTaskInternal` remains the single transition authority. Flag-on, it swaps the `VALID_TRANSITIONS` lookup for workflow-resolved column-graph validation (`resolveAllowedColumns`/`workflowHasColumn` in `workflow-transitions.ts`) plus sync trait guards run in-lock; rejections are typed `TransitionRejection`s. `VALID_TRANSITIONS` and the closed `Column`/`COLUMNS` helpers in `types.ts` are `@deprecated` while the flag exists — retained as the flag-off authority and the parity oracle, not yet removed. @@ -1898,7 +1895,7 @@ Task create/update now preserves both branch fields end-to-end: - **Durable persistence (core store layer):** `packages/core/src/store.ts` - `TaskStore.createTask()` persists both `branch` and `baseBranch` on task creation. - `TaskStore.updateTask()` preserves existing PATCH semantics where explicit `null` clears either field. - - Fields round-trip through JSON and SQLite persistence via the shared task contract in `packages/core/src/types.ts`. + - Fields round-trip through JSON and PostgreSQL persistence via the shared task contract in `packages/core/src/types.ts`. ### Routing activity visibility @@ -2119,12 +2116,12 @@ The GitHub tracking state listener now attaches to every registered project stor ## 14) Key Design Decisions -1. **SQLite + WAL for local-first reliability** - - Chosen for simple deployment and strong transactional behavior - - WAL mode enables concurrent readers/writers with low ops overhead +1. **PostgreSQL for transactional authority** + - Embedded PostgreSQL preserves zero-config operation; `DATABASE_URL` supports external deployments + - Shared project/central schemas provide one async transactional model across runtimes 2. **Hybrid persistence (DB + filesystem blobs)** - - Structured metadata in SQLite, large text/artifacts in task directories + - Structured metadata in PostgreSQL, large text/artifacts in task directories - Keeps DB efficient while preserving inspectable task artifacts 3. **Git worktree isolation as core execution primitive** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 47e2d97d54..9f2d16f2a5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -58,7 +58,7 @@ When `--project` is not supplied, Fusion resolves project context in this order: 1. Explicit `--project` flag 2. Default project (set via `fn project set-default `) -3. Current-directory auto-detection (`.fusion/fusion.db` lookup upward) +3. Current-directory auto-detection (`.fusion/project.json` lookup upward; legacy `fusion.db` is recognized only for migration) --- @@ -590,10 +590,10 @@ fn task logs FN-001 --follow --limit 50 --type tool - unavailable-node policy value - source provenance line (`Source: `), including parent task / GitHub issue URL context when present -Every `fn task` subcommand that touches the board retries on lock (FN-7731, -generalized to all subcommands in FN-7734): if the board database -(`.fusion/fusion.db`) is momentarily locked by the engine or another agent, -the command retries with bounded exponential backoff instead of failing +Every `fn task` subcommand that touches the board retries transient storage +failures (FN-7731, generalized to all subcommands in FN-7734): if PostgreSQL +reports a retryable transaction or availability error, the command retries +with bounded exponential backoff instead of failing outright. If the lock hasn't cleared once the retry deadline (default 15s) is reached, the command fails fast with a clear, actionable, non-zero-exit error naming the task and operation rather than hanging. Override the @@ -635,7 +635,7 @@ lock, the canonical transient-lock case) and closes the resolved store BEFORE each `process.exit()` call, since `runDbVacuum` always exits explicitly and a pending `finally` does not run after `process.exit()`. MCP global-scope settings live in the file-backed `GlobalSettingsStore` -(`~/.fusion/settings.json`, no SQLite handle) and are intentionally left +(`~/.fusion/settings.json`, no database handle) and are intentionally left with no close and no lock-retry. All of the above honor the same `FUSION_CLI_LOCK_RETRY_MS` deadline override. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index af8a7a96bb..444f749c5c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -819,7 +819,7 @@ Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic AP ## Setup Warning Banner -The dashboard banner cluster can also show a one-time storage notice announcing that the next Fusion version replaces the current SQLite data store with an embedded Postgres backend; dismissing it stores a browser-local acknowledgement so it does not reappear. +The retired pre-cutover SQLite-to-PostgreSQL storage notice is no longer rendered. Current setup banners cover actionable provider and GitHub readiness only. The dashboard and New Task modal show setup warnings only after readiness checks finish. AI-provider warnings appear immediately because agents cannot work without a provider. GitHub warnings are delayed per project: Fusion records the first time GitHub OAuth and authenticated `gh` CLI are both missing, waits one day, and then shows **GitHub not connected** if GitHub is still unavailable. Reconnecting GitHub clears the timer so a later disconnect starts a fresh one-day grace period. @@ -1976,10 +1976,10 @@ UI surfaces: - Task cards show grouped/shared branch metadata for grouped tasks. - Clicking either grouped badge opens the dedicated **Group Task Modal** for that branch group. - Task detail renders a branch-group card with member landed progress. -- If a task references a stale/missing branch group, Task Detail shows a **Stale branch group reference** recovery message with **Reset branch group for this task**. The action uses the supported assign API to clear only the current task's context, then reloads the detail so the card disappears and the task can proceed ungrouped without raw SQLite surgery. +- If a task references a stale/missing branch group, Task Detail shows a **Stale branch group reference** recovery message with **Reset branch group for this task**. The action uses the supported assign API to clear only the current task's context, then reloads the detail so the card disappears and the task can proceed ungrouped without direct database surgery. - In Task Detail Logs on mobile, the branch-group card includes a collapse/expand toggle so logs can reclaim vertical space while keeping group summary progress visible. -The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. Branch groups are durable SQLite state keyed by real `BG-*` ids, so valid grouped tasks continue to list/show after a server restart. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events. +The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. Branch groups are durable PostgreSQL state keyed by real `BG-*` ids, so valid grouped tasks continue to list/show after a server restart. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events. > **FN-7532:** a member only counts as "landed" once it merge-confirms onto its OWN group's branch via the branch-group-integration path (`mergeDetails.mergeTargetSource === "branch-group-integration"` and a matching `mergeTargetBranch`) — this is the same predicate the engine's promotion gate uses, so the checklist can never show "complete" when a real promotion would still be refused (or vice versa). The merge engine now stamps this attribution for every merge (previously only the legacy merge path did, so shared-group members merged through the current path were undercounted). diff --git a/docs/dashboard-realtime.md b/docs/dashboard-realtime.md index e552ab1775..1b7d39f3d8 100644 --- a/docs/dashboard-realtime.md +++ b/docs/dashboard-realtime.md @@ -200,7 +200,7 @@ Client behavior (`useRemoteNodeEvents.ts`): 3. **Project-switch stale callbacks not guarded** - old project events mutate current project state. 4. **Store instance mismatch for project streams** - - same SQLite DB is not enough; EventEmitter instance identity matters for realtime propagation. + - sharing the same PostgreSQL database is not enough; EventEmitter instance identity matters for realtime propagation. 5. **Background SSE when view does not need it** - unnecessary connection pressure and noisy updates. 6. **Ambiguous stream ownership** diff --git a/docs/multi-project-sequencing.md b/docs/multi-project-sequencing.md index bdace38bb0..fc8e22143c 100644 --- a/docs/multi-project-sequencing.md +++ b/docs/multi-project-sequencing.md @@ -11,8 +11,8 @@ Evidence highlights: - `packages/core/src/central-db.ts` defines `projects(id, path UNIQUE, nodeId, settings)` where `id` is canonical registry identity and `path` is a unique local filesystem location. - `packages/core/src/central-core.ts` generates `RegisteredProject.id` (`proj_`), stores absolute `path`, and treats node assignment via `assignProjectToNode()` / `unassignProjectFromNode()` as separate from registration. - `docs/multi-project.md` already distinguishes runtime placement (`projects.nodeId`) from task-routing defaults (`defaultNodeId`). -- `packages/core/src/plugin-store.ts` persists plugin rows in per-project `.fusion/fusion.db` today (project-root-scoped store). -- FN-3182 spec moves plugin install metadata to central DB with per-project state keyed by project path; FN-3503 introduces project-per-node path mappings to avoid assuming identical absolute paths on every node. +- `packages/core/src/async-plugin-store.ts` persists global plugin installs in PostgreSQL `central.plugin_installs` and project activation state in `central.project_plugin_states`. +- FN-3182's global-install/project-activation split is implemented; project activation remains keyed by normalized project path, so FN-3503's node-specific path mapping remains relevant to future cluster-wide identity alignment. ## Identity model @@ -21,7 +21,7 @@ Current identity boundaries are not interchangeable: - `projects.path` (local absolute path): host-local location; unique in one registry DB, but not portable identity across nodes. - `projects.nodeId` (runtime placement): where a project runtime is hosted; not a task routing default and not a filesystem mapping key. - Project settings `defaultNodeId` (task dispatch default): separate from runtime placement (`docs/multi-project.md`). -- Plugin scope today is project-local (`PluginStore(rootDir)`); FN-3182 proposes global install + project-scoped enablement, but its draft model still depends on path-based keys and therefore intersects FN-3503 identity work. +- Plugin scope is global install + project-scoped enablement. The activation model still depends on path-based keys and therefore intersects FN-3503 identity work. Implication: any multi-node plugin/project-state design that treats `projects.path` as cluster identity will conflict with FN-3503’s per-node path mapping direction. diff --git a/docs/multi-project.md b/docs/multi-project.md index c3e003f955..a7daeade35 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -4,6 +4,8 @@ Fusion can coordinate multiple repositories from one installation, with shared visibility and global concurrency control. +The [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-review-2026-07-14.md) is the current authority for legacy-reader and deployment boundaries. + ## Why Use Multi-Project Mode? Use multi-project mode when you need to: @@ -12,11 +14,9 @@ Use multi-project mode when you need to: - Standardize settings and workflows across projects - Monitor global activity and system-wide execution capacity -## Central Database Architecture +## Central Registry Architecture -Multi-project metadata is stored in: - -`~/.fusion/fusion-central.db` +Multi-project metadata is stored in the PostgreSQL `central` schema. Embedded mode uses Fusion's managed PostgreSQL data directory; external mode uses `DATABASE_URL`. Core tables: @@ -30,11 +30,11 @@ Core tables: - `taskClaims` (authoritative cross-node task checkout claims keyed by `(projectId, taskId)`) - `__meta` -Per-project task data remains in each repo’s `.fusion/fusion.db`. +Per-project task data is keyed by `projectId` in PostgreSQL's `project` schema. Each repo keeps `.fusion/project.json` as its filesystem identity marker; `.fusion/fusion.db` is read only by the one-time legacy migrator. -Backups now include this central DB alongside project backups: each `fn backup --create` run writes a paired `fusion-central-(-N).db` next to `fusion-(-N).db` under `.fusion/backups/` in the active project. Restore operations create a central pre-restore snapshot `fusion-central-pre-restore-.db` before replacing `~/.fusion/fusion-central.db`. +Use PostgreSQL-native backup/restore tooling for authoritative runtime data. Legacy `fn backup` SQLite artifacts remain migration/recovery inputs; restoring one does not replace the live PostgreSQL registry. -`taskClaims` is the central cross-node lease mutex introduced by FN-4819 §2: claim acquisition/renewal/release happen in `~/.fusion/fusion-central.db`, while per-project lease fields mirror the central winner for local scheduler/runtime consumption. +`taskClaims` is the central cross-node lease mutex introduced by FN-4819 §2: claim acquisition/renewal/release happen in PostgreSQL, while per-project lease fields mirror the central winner for local scheduler/runtime consumption. Peer/mesh coordination spans core + engine, with startup ownership in CLI process entrypoints: @@ -61,17 +61,17 @@ If one side succeeds and the other fails, the next scheduler/self-healing tick r This fencing prevents double-claims: a restarted or delayed stale owner cannot reclaim work once central ownership has been released and lease generation has advanced. -## Recovering after a central DB wipe +## Recovering a missing central project row -If a project's row is deleted from `~/.fusion/fusion-central.db`, Fusion now automatically recovers on next startup: +If a project's PostgreSQL central-registry row is deleted, Fusion recovers it on next startup: 1. Startup checks central for a row at the project path. -2. If missing, it reads `__meta.projectIdentity` from `/.fusion/fusion.db`. +2. If missing, it reads `/.fusion/project.json` (or imports a legacy SQLite identity once). 3. If present, central reattaches that exact `projectId` instead of creating a new one. This prevents “empty workspace” regressions where project data still exists locally but is keyed to an older `projectId`. -Backups remain the first-line protection strategy (see FN-5407), but this identity reattach path lets operators recover even when no central backup is available. +PostgreSQL backups remain the first-line protection strategy, but this identity reattach path restores the path-to-project mapping without minting a new ID. ## Registering and Managing Projects @@ -119,7 +119,7 @@ A singleton central record enforces system-wide limits so one project cannot mon Plugin persistence is split across global and project scopes: -- Global installation metadata is shared across projects in `~/.fusion/fusion-central.db` (`plugin_installs`) +- Global installation metadata is shared across projects in PostgreSQL `central.plugin_installs` - Per-project activation/runtime state is tracked separately per normalized project path (`project_plugin_states`) - Project-local `.fusion/fusion.db` `plugins` rows are legacy migration-only input and are no longer a write target for installs @@ -139,9 +139,9 @@ Projects can run with: Multi-project deployments use three related node/path records at different layers: -1. **Project runtime placement** (`projects.nodeId` in `~/.fusion/fusion-central.db`) +1. **Project runtime placement** (`central.projects.nodeId` in PostgreSQL) - Decides where a project runtime is hosted in multi-project orchestration. -2. **Project working-directory mapping** (`projectNodePathMappings` in `~/.fusion/fusion-central.db`) +2. **Project working-directory mapping** (`central.projectNodePathMappings` in PostgreSQL) - Stores the absolute path for a project on each node (`projectId` + `nodeId` key). - Local mappings are auto-created from `projects.path` at registration and kept in sync when local canonical path changes. 3. **Task dispatch default** (`defaultNodeId` in project settings) @@ -289,14 +289,9 @@ On first run after upgrade: Migration is idempotent and designed to avoid repeated re-registration. -## Rollback Procedure +## Backend rollback -If central registry behavior needs to be reverted: - -1. Delete `~/.fusion/fusion-central.db` -2. Keep using per-project `.fusion/fusion.db` data -3. Fusion falls back to legacy/single-project behavior -4. Re-register projects later with `fn init` / `fn project add` +There is no SQLite runtime rollback. Do not delete PostgreSQL data or set `FUSION_NO_EMBEDDED_PG`; the flag now fails startup. Restore PostgreSQL from backup or point `DATABASE_URL` at a recovered database, then run `fn init` / `fn project add` only to repair project registration metadata. ## Runtime Architecture @@ -350,8 +345,8 @@ See also: [Architecture](./architecture.md), [CLI Reference](./cli-reference.md) ## Identity persistence and recovery -Each project persists its canonical central identity inside `.fusion/fusion.db` `__meta` as `projectId` and `projectCreatedAt`. Registration paths should use `CentralCore.ensureProjectForPath({ path, identity, ... })` after reading local identity with `readProjectIdentity()`; this reattaches central rows when central was wiped and refuses silent remint if the persisted id is owned by another path. +Each project persists its canonical central identity in `.fusion/project.json` as `id` and `createdAt`. Registration paths use `CentralCore.ensureProjectForPath({ path, identity, ... })` after `readProjectIdentity()`; that reader accepts a legacy SQLite identity only as migration input. Reattachment refuses silent remint when the persisted ID belongs to another path. Dashboard `POST /api/projects` now surfaces this mismatch as `409` with `error: "orphan-identity"` and recovery metadata, and callers can opt into recovery flows with `acceptRecovery: true` behavior at the route layer. -Central DB backup coverage is already enabled by default (`BackupManager` uses `includeCentralDb: true`), so identity recovery data remains in the normal daily backup set. +Back up PostgreSQL with the deployment's PostgreSQL backup tooling; `.fusion/project.json` is identity metadata, not a substitute for a database backup. diff --git a/docs/performance/dashboard-load.md b/docs/performance/dashboard-load.md index 6bc4808536..d3454bf6ec 100644 --- a/docs/performance/dashboard-load.md +++ b/docs/performance/dashboard-load.md @@ -1,5 +1,7 @@ # Dashboard Load Performance Analysis +> Historical pre-cutover analysis: the SQLite query plans below do not describe the current PostgreSQL runtime. Retained for performance archaeology and regression context. + **Date:** 2026-04-10 **Task:** FN-1532 diff --git a/docs/plugin-management.md b/docs/plugin-management.md index 13bf2f1858..9ec198f011 100644 --- a/docs/plugin-management.md +++ b/docs/plugin-management.md @@ -44,8 +44,8 @@ Setup-capable plugins (for example **Agent Browser**) expose an additional **set Plugin persistence is split across global and project scopes: -- Plugin installs are **global**. Installation metadata is registered in `~/.fusion/fusion-central.db` and shared across all projects on this machine. -- Enable/disable is **project-scoped**. The same globally installed plugin can be enabled in one project and disabled in another. Per-project activation/runtime state is tracked in `project_plugin_states`. +- Plugin installs are **global**. Installation metadata is registered in PostgreSQL `central.plugin_installs` and shared across all projects on this Fusion control plane. +- Enable/disable is **project-scoped**. The same globally installed plugin can be enabled in one project and disabled in another. Per-project activation/runtime state is tracked in PostgreSQL `central.project_plugin_states`. - `fn plugin list` shows the global install set plus enabled/disabled state for the current project context. For full multi-project details, see [Plugin Scope in Multi-Project Mode](./multi-project.md#plugin-scope-in-multi-project-mode). diff --git a/docs/postgres-migration-review-2026-06-26.md b/docs/postgres-migration-review-2026-06-26.md index f049d3be32..ae72697b75 100644 --- a/docs/postgres-migration-review-2026-06-26.md +++ b/docs/postgres-migration-review-2026-06-26.md @@ -1,5 +1,7 @@ # Code Review — SQLite → PostgreSQL Storage Migration +> **Historical review:** This document records the incomplete migration branch as reviewed on 2026-06-26. Its NOT READY verdict and line-specific findings are not the current cutover status. See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-review-2026-07-14.md) for the audited current architecture, residual legacy-reader inventory, and deployment contract. + **Date:** 2026-06-26 **Branch:** `feature/postgres` reviewed against `origin/main` (merge-base `7d13f880b`) **HEAD:** `387cec1a7` — `feat: migrate storage from SQLite to PostgreSQL (squash)` @@ -119,7 +121,7 @@ The backup subsystem is independently broken three ways in the default embedded 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. +Historical note: the original checklist included rollback via `FUSION_NO_EMBEDDED_PG=1`. The final cutover removed that runtime fallback; recovery now restores the retained SQLite backup into a controlled migration workflow, while normal startup always uses embedded PostgreSQL or `DATABASE_URL`. --- diff --git a/docs/postgres-migration-review-2026-07-14.md b/docs/postgres-migration-review-2026-07-14.md new file mode 100644 index 0000000000..fa3c87959f --- /dev/null +++ b/docs/postgres-migration-review-2026-07-14.md @@ -0,0 +1,108 @@ +# PostgreSQL Runtime Cutover Review + +**Date:** 2026-07-14 +**Scope:** End-to-end runtime, migration, plugin, operator-documentation, and deployment audit +**Current authority:** PostgreSQL is mandatory for Fusion runtime metadata + +> This review supersedes the readiness verdict in the [2026-06-26 migration review](./postgres-migration-review-2026-06-26.md). That earlier document remains an historical record of the incomplete migration branch and its original findings. + +## Verdict + +Fusion no longer supports SQLite as a live runtime backend. Startup selects either Fusion-managed embedded PostgreSQL or an external PostgreSQL target supplied through `DATABASE_URL`; failure to establish PostgreSQL is fatal. The former `FUSION_NO_EMBEDDED_PG` escape hatch is rejected rather than selecting SQLite. + +Legacy `fusion.db`, `archive.db`, and `fusion-central.db` files remain readable only at controlled identity-discovery and one-time migration/import seams. They are never a supported write target or runtime fallback. `.fusion/project.json` is the local project identity marker after cutover. + +## PostgreSQL-authoritative inventory + +| Surface | PostgreSQL authority | +|---|---| +| Project registry, nodes, task claims, global settings, global secrets, plugin installs and activation | `central` schema | +| Active and soft-deleted tasks, workflow state, comments, attachments, documents, artifacts, approvals, agent/chat/session state, messages, automations, routines, insights, research, Todo, missions, knowledge metadata, operational logs | `project` schema, scoped by canonical `project_id` | +| Archived task snapshots and archive search | `archive.archived_tasks`, scoped by canonical `project_id` | +| Reports, CLI Printing Press, Compound Engineering, Roadmap, Even Realities, and other bundled plugin state | Plugin-owned PostgreSQL tables with project ownership and isolation | +| WhatsApp authentication/session persistence | PostgreSQL-backed plugin persistence | +| Filesystem identity and large/task-local payloads | `.fusion/project.json`, task files, attachments, artifacts, and `agent-log.jsonl`; these are compatibility/blob surfaces, not SQLite authority | + +The runtime construction boundary supplies an async PostgreSQL data layer to `TaskStore`, dashboard project stores, CLI commands, desktop runtime, engine runtime, and bundled plugins. Store creation without that layer fails closed instead of silently constructing SQLite authority. + +## Cutover fixes completed + +- Made PostgreSQL startup mandatory and removed the environment-controlled SQLite fallback. +- Centralized project identity on `.fusion/project.json` plus `central.projects`; legacy SQLite identity is imported only when the marker/registry needs initial recovery. +- Completed active-task, cold-archive, workflow, mission, research, Todo, knowledge, CLI-session, maintenance, and phantom-reservation PostgreSQL paths. +- Scoped active, archive, and plugin rows by canonical project identity and applied project isolation constraints/policies. +- Made archive list/search/restore behavior PostgreSQL-native, project-isolated, and bounded where the board/API contract is paginated. +- Ported bundled plugin persistence, including Reports, CLI Printing Press, Compound Engineering, Roadmap, Even Realities, and WhatsApp, away from runtime SQLite access. +- Removed the dashboard's obsolete “PostgreSQL is coming next version” banner. +- Made CLI, dashboard, desktop, and in-process runtime teardown retain and close the PostgreSQL owner exactly once, including startup-failure paths. +- Ported maintenance and repair scripts that operate on live Fusion data to the PostgreSQL backend helper. +- Updated current operator/developer documentation so SQLite descriptions are limited to explicitly historical or migration-only material. + +## Intentional remaining SQLite readers + +The following readers are authorized after cutover. Their scope is deliberately narrow and read-only: + +| Reader | Authorized purpose | +|---|---| +| `packages/core/src/postgres/sqlite-migrator.ts` | Inventory, validate, copy, and verify legacy project/archive/central SQLite sources during one-time import. | +| `packages/core/src/project-identity.ts` | Recover a legacy project ID when `.fusion/project.json` has not yet been written. | +| `packages/core/src/sqlite-validation.ts` | Validate a retained legacy SQLite source before migration/recovery. | +| `packages/core/src/postgres/startup-factory.ts` | Import a legacy central registry during the controlled first PostgreSQL startup. | +| `packages/cli/src/commands/db.ts` | Explicit migration/dry-run and legacy-source inspection, including read-only central-source discovery. | +| `scripts/lib/start-local-project.mjs` | Read legacy local project metadata while the development launcher resolves a project; it never supplies runtime database authority. | + +Legacy SQLite adapter/store modules and exports may remain for migration compatibility and historical tests, but mandatory runtime constructors do not select them. Any new production call that opens one for ordinary task, dashboard, engine, CLI, desktop, or plugin traffic is a cutover regression. + +The following file roles are not SQLite database authority and should not be confused with a fallback: + +- `.fusion/project.json`: canonical local identity marker. +- `.fusion/tasks/{ID}/task.json`: compatibility/debug material used by guarded reconciliation. +- `.fusion/tasks/{ID}/agent-log.jsonl`: intentional file-backed agent log. +- Retained `fusion.db`, `archive.db`, and `fusion-central.db`: immutable migration/recovery evidence after successful import. + +## Removed runtime fallback contract + +- Normal startup must not continue without a healthy PostgreSQL connection. +- `FUSION_NO_EMBEDDED_PG` is obsolete and rejected. +- `fusion.db` presence is only a migration signal; it is not sufficient project identity after `.fusion/project.json` has been established. +- Dashboard, engine, CLI, desktop, and plugin stores must not construct an operational SQLite store when their PostgreSQL owner is unavailable. +- A migration failure is visible and blocking. Fusion must not hide it by starting against an empty alternate backend. + +## Deployment, backup, restore, and rollback + +Treat the first production cutover as a maintenance-window migration: + +1. Quiesce every engine, dashboard, daemon, desktop runtime, scheduler, automation, and plugin writer. Only one migration owner may run. +2. Record canonical project-path-to-`project_id` mappings and baseline row counts/status distributions. +3. While legacy writers are stopped, copy each legacy SQLite file together with any `-wal`/`-shm` companion and record SHA-256 plus `PRAGMA quick_check` output. Store that evidence off-host. +4. Create a full PostgreSQL backup that includes `central`, `project`, `archive`, plugin tables, and public migration bookkeeping. The built-in paired project/central dumps are not a single cluster-wide snapshot, so a quiesced full-cluster backup remains the deployment safety boundary. +5. Restore the backup into an isolated scratch database and run row-count, schema-version, ownership, and project-isolation checks there. Listing a dump is not a restore test. +6. Use PostgreSQL 15-compatible `pg_dump`, `pg_restore`, and `psql` clients. For external transaction poolers, provide a direct `DATABASE_MIGRATION_URL` for schema work. +7. Run the migration preview, then the migration once from one approved owner. `fn db migrate` is the recommended explicit external-database path; first startup retains a fail-safe verified auto-import for either backend so Fusion never boots an empty PostgreSQL authority over valid legacy data. +8. Before resuming writers, require complete migration markers, no failed/running marker, no unexplained `__legacy_unscoped__` or stale `local-*` partition, matching baselines, and a project-A-cannot-read-project-B isolation proof. + +Rollback is restore-only. Stop writers, preserve failure evidence, and restore the tested PostgreSQL backup/snapshot. Do not try to roll back by enabling SQLite or by writing new runtime data into the retained legacy files. If a legacy import must be retried, restore/copy its immutable source into a controlled migration workspace and re-run the supported migration workflow. + +## Verification record + +| Verification | Result | +|---|---| +| Focused PostgreSQL migration, identity, archive, workflow, mission, plugin, maintenance, and lifecycle tests | PASS — all targeted suites green, including concurrency, ownership, failure, and real-runtime composition cases | +| `pnpm --filter @fusion/core typecheck` | PASS | +| `pnpm --filter @fusion/engine typecheck` | PASS | +| `pnpm --filter @fusion/dashboard typecheck` | PASS | +| `pnpm --filter @runfusion/fusion typecheck` | PASS | +| `pnpm --filter @fusion/desktop typecheck` | PASS | +| `pnpm check:changesets` | PASS | +| `pnpm lint` | PASS | +| `pnpm build` | PASS (only existing Vite chunk/dynamic-import warnings) | +| `pnpm test:gate` | PASS — 40 files and 478 tests | +| `pnpm smoke:boot` | PASS — CLI help, health 200 on an ephemeral port, clean shutdown | +| `pnpm verify:fast` | PASS — artifact bootstrap, CLI build, and boot smoke | +| `git diff --check` and final production SQLite-reader grep | PASS — exactly the six documented read-only legacy boundaries remain | + +The explicit full workspace suite remains opt-in and is not a substitute for the thin merge gate or the file-scoped PostgreSQL regression tests. + +## Ongoing guardrail + +For every new persistence surface, require a PostgreSQL round-trip test, canonical `project_id` ownership where applicable, a previous-state migration test for schema changes, lifecycle cleanup coverage, and a repository-wide search proving no new runtime `DatabaseSync`/`node:sqlite` path was introduced. Update this review if the authorized legacy-reader inventory changes. diff --git a/docs/research.md b/docs/research.md index a4767007d9..1216dcf76d 100644 --- a/docs/research.md +++ b/docs/research.md @@ -332,7 +332,7 @@ See [Agents → Research Tools](./agents.md) for more details. ## Storage -Research data is persisted in the project SQLite database (`.fusion/fusion.db`) using three tables: +Research data is persisted in the project PostgreSQL schema, isolated by `project_id`, using three tables: ### `research_runs` @@ -346,15 +346,15 @@ Primary table for research run state. | `status` | TEXT | Current run status | | `projectId` | TEXT | Optional project scope | | `trigger` | TEXT | Optional trigger source | -| `providerConfig` | TEXT (JSON) | Provider configuration used | -| `sources` | TEXT (JSON) | Array of research sources | -| `events` | TEXT (JSON) | Array of run events | -| `results` | TEXT (JSON) | Research results (findings, summary, citations) | +| `providerConfig` | JSONB | Provider configuration used | +| `sources` | JSONB | Array of research sources | +| `events` | JSONB | Array of run events | +| `results` | JSONB | Research results (findings, summary, citations) | | `error` | TEXT | Error message if failed | -| `tokenUsage` | TEXT (JSON) | Token usage metrics | -| `tags` | TEXT (JSON) | String array of tags | -| `metadata` | TEXT (JSON) | Arbitrary metadata | -| `lifecycle` | TEXT (JSON) | Lifecycle details (attempts, retry info, failure class) | +| `tokenUsage` | JSONB | Token usage metrics | +| `tags` | JSONB | String array of tags | +| `metadata` | JSONB | Arbitrary metadata | +| `lifecycle` | JSONB | Lifecycle details (attempts, retry info, failure class) | | `createdAt` | TEXT | ISO timestamp | | `updatedAt` | TEXT | ISO timestamp | | `startedAt` | TEXT | When execution began | @@ -389,7 +389,7 @@ Append-only event log for run lifecycle tracking. | `message` | TEXT | Human-readable message | | `status` | TEXT | Run status at event time | | `classification` | TEXT | Failure classification if applicable | -| `metadata` | TEXT (JSON) | Arbitrary metadata | +| `metadata` | JSONB | Arbitrary metadata | | `createdAt` | TEXT NOT NULL | ISO timestamp | Index: `(runId, seq)` for ordered retrieval. diff --git a/docs/sandbox.md b/docs/sandbox.md index aa8f531860..b9bb5f64a9 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -34,7 +34,7 @@ Use `fusionWorktreePreset(ctx)` to get the standard Fusion-friendly defaults: - Worktree writable - pnpm store writable -- `.fusion/fusion.db` is not added to writable mounts +- `.fusion/` compatibility metadata and task artifacts are not added to writable mounts ### Troubleshooting @@ -71,7 +71,7 @@ Port 4040 is always blocked in the emitted SBPL profile (`(deny network-bind (lo ### `.fusion/` write guard -Writable paths under `.fusion/` (including `.fusion/fusion.db` and `.fusion/tasks/**`) are rejected by policy validation. +Writable paths under `.fusion/` (including `.fusion/project.json`, retained migration inputs, and `.fusion/tasks/**`) are rejected by policy validation. ### Troubleshooting diff --git a/docs/secrets.md b/docs/secrets.md index e6502abd3e..86d9d2af20 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -4,7 +4,7 @@ ## Overview -Fusion's secrets subsystem provides encrypted-at-rest secret storage with project scope (`.fusion/fusion.db`) and global scope (`~/.fusion/fusion-central.db`). +Fusion's secrets subsystem provides encrypted-at-rest secret storage in PostgreSQL, with project scope in `project.secrets` and global scope in `central.secrets_global`. ## Implementation Status @@ -35,7 +35,7 @@ Current shipped behavior in this branch includes: Threat-model baseline: -- Secret plaintext is **not** stored in SQLite. +- Secret plaintext is **not** stored in PostgreSQL. - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. - Secret values must never be logged. - MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings. @@ -45,10 +45,10 @@ See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Archite ## Architecture -Fusion stores secrets in two SQLite tables: +Fusion stores secrets in two PostgreSQL tables: -- Project scope: `secrets` in `.fusion/fusion.db` -- Global scope: `secrets_global` in `~/.fusion/fusion-central.db` +- Project scope: `project.secrets`, isolated by project identity +- Global scope: `central.secrets_global` Both tables share the same column contract: @@ -56,8 +56,8 @@ Both tables share the same column contract: |---|---|---| | `id` | `TEXT` | Primary key UUID. | | `key` | `TEXT` | Unique secret key (`idxSecretsKey` / `idxSecretsGlobalKey`). | -| `value_ciphertext` | `BLOB` | AES-GCM ciphertext payload (includes auth tag). | -| `nonce` | `BLOB` | Per-row random nonce. | +| `value_ciphertext` | `BYTEA` | AES-GCM ciphertext payload (includes auth tag). | +| `nonce` | `BYTEA` | Per-row random nonce. | | `description` | `TEXT` | Optional metadata. | | `access_policy` | `TEXT` | `CHECK` constrained to `auto`, `prompt`, `deny`. | | `env_exportable` | `INTEGER` | `0/1` flag for env-materialization intent metadata. | @@ -206,7 +206,7 @@ Track follow-up: **FN-5031** (missing `packages/core/src/__tests__/secrets-env.t ## Operational Notes -- Backups: preserve both SQLite data and master-key material/provider source used by deployment. +- Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority. - If master key material is lost, encrypted secret values become unrecoverable. - Pending advanced capabilities: - Master-key rotation UX and key lifecycle tooling diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5827e10fc4..6a133e8a2d 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -565,7 +565,7 @@ Default notes: | `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). | | `verificationCommandTimeoutMs` | `number` | `undefined` | Optional project-scoped default timeout in milliseconds for executor `fn_run_verification` and configured deterministic test/build verification commands. When unset, `fn_run_verification` keeps its scope defaults (300s package, 900s workspace); when set to a positive value, it overrides both scope defaults while all verification still respects the 1800s hard cap. Set `0` or leave unset to use the legacy scope defaults. Marathon command shapes (`pnpm test`, `pnpm test:full`, `pnpm verify:workspace`, whole-package tests without file filters, and repeat loops) are soft-capped unless the agent explicitly passes `allowFullSuite: true`; opt-in full-suite runs still emit progress heartbeats and obey the hard cap. Project settings override global/default settings via the normal project settings precedence. | | `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. | -| `ephemeralAgentsEnabled` | `boolean` | `true` | Defaults to `true` for both new projects (seeded into `.fusion/fusion.db` on init) and upgrades from pre-FN-4153 projects (falls back to `true` whenever the persisted `config.settings` row omits the key). Users who explicitly set `false` keep that choice. When enabled, Fusion spawns short-lived `executor-FN-XXXX` workers for task execution. When disabled, only permanent executor agents run tasks; the scheduler auto-assigns dispatchable tasks using reporting-chain-aware load balancing, and tasks stay queued until an eligible permanent executor is available. | +| `ephemeralAgentsEnabled` | `boolean` | `true` | Defaults to `true` for new projects and upgrades from pre-FN-4153 projects (falls back to `true` whenever the persisted PostgreSQL project settings omit the key). Users who explicitly set `false` keep that choice. When enabled, Fusion spawns short-lived `executor-FN-XXXX` workers for task execution. When disabled, only permanent executor agents run tasks; the scheduler auto-assigns dispatchable tasks using reporting-chain-aware load balancing, and tasks stay queued until an eligible permanent executor is available. | | `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). | | `sandboxProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; autoApproveBackendIds?: string[] }` | `{}` | Approval policy for sandbox host-bootstrap operations (backend install/pull/probe during `SandboxBackend.prepare()`). Default posture is strict: `approvalMode` resolves to `always`; `autoApproveBackendIds` defaults to `["native"]`. | | `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. | @@ -709,7 +709,7 @@ GitLab configuration examples: leave both URL fields blank for GitLab.com (`http | `chatDefaultModelId` | `string` | `undefined` | Model id for the model-mode Direct-chat default; must pair with `chatDefaultModelProvider`. | | `chatDefaultThinkingLevel` | `ThinkingLevel` | `undefined` | Optional thinking-level override for model-mode New Chat defaults. Empty/unset inherits the resolved project/global default thinking level. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | -| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`), terminal `agentRuns` rows (by `endedAt`), and `agentConfigRevisions` (by `createdAt`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes timestamped operational-log rows older than this many days while always preserving in-flight `agentRuns` (`endedAt IS NULL`) and the most-recent `agentConfigRevisions` row per agent. | +| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for PostgreSQL operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`), terminal `agentRuns` rows (by `endedAt`), and `agentConfigRevisions` (by `createdAt`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes timestamped operational-log rows older than this many days while always preserving in-flight `agentRuns` (`endedAt IS NULL`) and the most-recent `agentConfigRevisions` row per agent. | | `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. | | `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). | | `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). | @@ -874,7 +874,7 @@ Routing precedence for task dispatch is: ### Project Default Node vs central project node assignment -Fusion also stores `projects.nodeId` in the **central registry database** (`~/.fusion/fusion-central.db`). That value is a multi-project runtime placement field used by `ProjectManager` (for selecting remote vs local project runtime), not the same setting as `defaultNodeId` task dispatch routing. +Fusion also stores `projects.nodeId` in the PostgreSQL **central registry**. That value is a multi-project runtime placement field used by `ProjectManager` (for selecting remote vs local project runtime), not the same setting as `defaultNodeId` task dispatch routing. Node-specific project working directories are persisted separately in central DB table `projectNodePathMappings` (`projectId` + `nodeId` + `path`). Do not treat `projects.nodeId` as the path source of truth. diff --git a/docs/storage.md b/docs/storage.md index 764c910a11..73f458db4e 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -1,23 +1,27 @@ # Fusion Dashboard Storage Audit (FN-1202) +> Current authority: Fusion runtime metadata lives in PostgreSQL. `.fusion/project.json` is the local identity marker; `fusion.db`, `archive.db`, SQLite inventories, FTS5 notes, and worktree-DB hydration sections below are retained only as pre-cutover migration/history records and do not describe a supported runtime fallback. + +See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-review-2026-07-14.md) for the audited authority inventory, exact authorized legacy readers, and deployment/rollback checklist. + ## Task-ID allocator authority and compatibility - `distributed_task_id_state` is the authoritative local task-ID allocator state. `nextSequence` is the active high-water mark used for local ID reservations. -- `distributed_task_id_reservations` tracks reserve/commit/abort lifecycle entries. Aborted/expired reservations are burned and never reissued. Create-class writes commit the reservation in the same SQLite transaction as the `tasks` row insert, then roll back the row/partial directory and move the reservation to aborted if post-insert `task.json`/`PROMPT.md` materialization or create validation fails. +- `distributed_task_id_reservations` tracks reserve/commit/abort lifecycle entries. Aborted/expired reservations are burned and never reissued. Create-class writes commit the reservation in the same PostgreSQL transaction as the `tasks` row insert, then roll back the row/partial directory and move the reservation to aborted if post-insert `task.json`/`PROMPT.md` materialization or create validation fails. - `config.nextId` is retained only as a deprecated legacy compatibility field and optional one-time seed source. Fusion still reads it during reconciliation, but runtime task creation and settings writes no longer mutate it. - Startup/store-open allocator reconciliation bumps each active prefix sequence to `max(current nextSequence, max(tasks suffix)+1, max(archivedTasks suffix)+1, max(reservation sequence)+1)` so stale allocator rows self-heal before local task creation resumes. -- Create-class task persistence is intentionally non-destructive: new tasks use plain `INSERT` semantics, while `ON CONFLICT(id) DO UPDATE` remains update-only. If counters drift and a reserved ID still collides, the create fails and the existing SQLite row / task directory stays intact. A `committed` distributed reservation is valid only with a matching durable task row/directory; failed creates burn the reservation as `aborted` instead of leaving a committed-reservation-without-task phantom. +- Create-class task persistence is intentionally non-destructive: new tasks use plain `INSERT` semantics, while upserts remain update-only. If counters drift and a reserved ID still collides, the create fails and the existing PostgreSQL row / task directory stays intact. A `committed` distributed reservation is valid only with a matching durable task row/directory; failed creates burn the reservation as `aborted` instead of leaving a committed-reservation-without-task phantom. ## Soft-deleted tasks (FN-5105) - User-initiated `TaskStore.deleteTask` is a **soft delete**: the task row stays in `tasks` and `deletedAt` is set. - Active task readers (`getTask`, `listTasks`, search, dependency scans, scheduler/watcher reads, mission task aggregations) must filter with `deletedAt IS NULL`. -- Archived-task flows (`archiveTask`, archived cleanup/migration) still hard-delete from the active `tasks` table after copying to cold storage (`archive.db`). +- Archived-task flows (`archiveTask`, archived cleanup/migration) hard-delete from the active `tasks` table after copying to PostgreSQL cold storage. Legacy `archive.db` files are import-only. - ID reservation is unchanged: soft-deleted IDs remain reserved. `distributed-task-id` and `task-id-integrity` intentionally scan all task rows (including soft-deleted rows), and must not filter on `deletedAt`. ### Orphaned task-dir reconciliation (FN-6783) -- Disk-backed `TaskStore` instances reconcile `.fusion/tasks/{ID}/task.json` directories against the SQLite `tasks` index on store open and during `SelfHealingManager` Batch 1 maintenance (`reconcile-orphaned-task-dirs`). This closes the visibility gap where a heartbeat-created task could exist on disk but be absent from `getTask`/`listTasks` and the dashboard board. +- PostgreSQL-backed `TaskStore` instances reconcile `.fusion/tasks/{ID}/task.json` compatibility artifacts against the PostgreSQL `project.tasks` table on store open and during `SelfHealingManager` Batch 1 maintenance (`reconcile-orphaned-task-dirs`). This closes the visibility gap where a heartbeat-created task could exist on disk but be absent from `getTask`/`listTasks` and the dashboard board. - The reconcile is non-destructive: when an ID already exists anywhere the create path would reserve it (active task row, soft-deleted row, archived table/archive DB, or tombstone), the scan skips the directory and never overwrites or resurrects that ID. Only a valid live `task.json` with no DB record anywhere is re-imported. - Recovered rows preserve the on-disk task metadata, including `column`, `status`, dependencies, steps, and log, after the same defensive disk normalization used by task JSON fallback reads. Malformed or unparseable `task.json` files are skipped with a warning instead of failing store open or maintenance. - Recovery is visible: each inserted orphan emits a store warning, a `task:reconcile-orphaned-task-dir` run-audit event, and a `task:created` lifecycle event so live boards can render the recovered card. @@ -25,12 +29,12 @@ ### Agent log storage + soft-delete visibility (FN-5143 / FN-5911) -- Agent logs are no longer stored in SQLite. Each task now appends newline-delimited JSON records to `/.fusion/tasks/{ID}/agent-log.jsonl`. +- Agent logs are stored outside PostgreSQL. Each task appends newline-delimited JSON records to `/.fusion/tasks/{ID}/agent-log.jsonl`. - Agent-log JSONL rows may include optional numeric timing metadata: `timeToFirstTokenMs` on the first visible model-output row for a request, and `durationMs` on tool/request completion rows such as `tool_result` or `tool_error`. These fields are additive, non-sensitive millisecond values; legacy rows may omit them and readers must continue to treat omission as normal. - `TaskStore.deleteTask` keeps that JSONL file on disk for forensics, but all live read APIs (`getAgentLogs*`, `getAgentLogCount`) gate on task liveness and return zero entries once `deletedAt` is set. -- Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged in spirit: archive payloads still embed a capped agent-log snapshot, now sourced from the JSONL file instead of `fusion.db`. -- Retention is now independent from SQLite operational-log pruning. `settings.agentLogFileRetentionDays` controls age-based pruning of JSONL entries for soft-deleted and archived tasks only. Default: `0` (disabled). -- SQLite operational-log pruning is controlled separately by `settings.operationalLogRetentionDays`. It now prunes `activityLog`, `runAuditEvents`, `agentHeartbeats`, terminal `agentRuns` rows by `endedAt`, and `agentConfigRevisions` by `createdAt`. +- Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) embeds a capped agent-log snapshot sourced from JSONL. +- Retention is independent from PostgreSQL operational-log pruning. `settings.agentLogFileRetentionDays` controls age-based pruning of JSONL entries for soft-deleted and archived tasks only. Default: `0` (disabled). +- PostgreSQL operational-log pruning is controlled separately by `settings.operationalLogRetentionDays`. It prunes `activityLog`, `runAuditEvents`, `agentHeartbeats`, terminal `agentRuns` rows by `endedAt`, and `agentConfigRevisions` by `createdAt`. - Safety invariants for operational pruning: in-flight `agentRuns` (`endedAt IS NULL`) are never deleted, and the most-recent `agentConfigRevisions` row per agent is always preserved even when older than the retention window. ### Archived-column pagination (FN-7659) @@ -38,7 +42,7 @@ - The Archived board column no longer loads the full archive into memory. `ArchiveDatabase.listPage(limit, offset)` reads a bounded page ordered `archivedAt DESC, rowid DESC` via SQL `LIMIT/OFFSET`, backed by the existing `idxArchivedTasksArchivedAt` index. - `TaskStore.listArchivedTasks({ limit, offset, slim })` is a dedicated, archive-only read path (default page size 100) that maps paged entries through `archiveEntryToTask` and returns `{ tasks, total, hasMore }` in `archivedAt DESC` order. It intentionally does **not** run the `createdAt ASC` sort used by the merged `listTasks({ includeArchived: true })` path — that merged path (and its non-board consumers: github-tracking reconciler, signal routes, agent-token-usage, self-healing) is unchanged. - `GET /tasks/archived?limit=&offset=` exposes the paged read with `projectId` scoping and `limit`/`offset` validation, returning the same `{ tasks, total, hasMore }` shape. -- The dashboard's `useTasks` hook loads page 1 on first Archived-column expand and fetches subsequent pages only via an explicit "Show more" click (`loadMoreArchivedTasks`); it never re-fetches the whole archive on SSE reconnect, tab-visibility recovery, or repeated expand calls. Fetched pages merge into the board `tasks` array de-duplicated by id, with active SQLite rows authoritative over archive snapshots. +- The dashboard's `useTasks` hook loads page 1 on first Archived-column expand and fetches subsequent pages only via an explicit "Show more" click (`loadMoreArchivedTasks`); it never re-fetches the whole archive on SSE reconnect, tab-visibility recovery, or repeated expand calls. Fetched pages merge into the board `tasks` array de-duplicated by id, with active PostgreSQL rows authoritative over archive snapshots. ### Activity-log no-op `task:moved` cleanup (FN-5940) @@ -46,7 +50,7 @@ - Defense is layered: the `task:moved` listener skips same-column transitions, and source emitters skip no-op `archived -> archived` / same-column polling re-emits before subscribers see them. - Existing junk rows are removed by a one-time init migration guarded by `__meta.noOpTaskMovedActivityCleanupVersion = "1"`. - The cleanup deletes only rows matching `type = 'task:moved'` where `json_extract(metadata, '$.from') = json_extract(metadata, '$.to')`; legitimate distinct-column moves are preserved. -- The migration does **not** run `VACUUM` automatically. After the delete lands on a large disk-backed DB, run `fn db --vacuum` manually to reclaim the freed space from the SQLite file. +- Historical migration note: the pre-cutover SQLite cleanup did **not** run `VACUUM` automatically. `fn db --vacuum` applies only while inspecting a retained legacy database and is not part of PostgreSQL operation. ### Dashboard delete-event handling (FN-5135) @@ -74,10 +78,10 @@ ### Artifact registry (FN-6777) -- `artifacts` is the first-class metadata registry for generated or uploaded task artifacts. Rows store ID, `type` (`document`, `image`, `video`, `audio`, or `other`), title/description, MIME type, size, author identity/type, optional task linkage, metadata JSON, textual `content`, a relative `uri`, and timestamps; binary bytes are not stored in SQLite. -- `TaskStore.registerArtifact()` writes task-scoped binary payloads under `/.fusion/tasks/{ID}/artifacts/` and task-less registry payloads under `/.fusion/artifacts/`, then records a relative `artifacts/` URI in SQLite. If the DB insert fails after a binary write, the store removes the orphaned file before surfacing the error. +- `artifacts` is the first-class PostgreSQL metadata registry for generated or uploaded task artifacts. Rows store ID, `type` (`document`, `image`, `video`, `audio`, or `other`), title/description, MIME type, size, author identity/type, optional task linkage, metadata JSON, textual `content`, a relative `uri`, and timestamps; binary bytes stay on disk. +- `TaskStore.registerArtifact()` writes task-scoped binary payloads under `/.fusion/tasks/{ID}/artifacts/` and task-less registry payloads under `/.fusion/artifacts/`, then records a relative `artifacts/` URI in PostgreSQL. If the DB insert fails after a binary write, the store removes the orphaned file before surfacing the error. - Image task attachments (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) and video task attachments (`video/mp4`, `video/webm`, `video/quicktime`; 100MB cap vs 5MB for other attachments) are bridged into the artifact registry by `TaskStore.addAttachment()` as `image`/`video` rows with `metadata.source: "attachment"` and a relative `attachments/` URI. This keeps one copy of the bytes under `/.fusion/tasks/{ID}/attachments/` while making the image discoverable through artifact list APIs and the Documents/Task Artifacts galleries. Non-image attachments remain attachment-only. Deleting an attachment also deletes its bridged artifact row before removing the attachment file so `/api/artifacts/:id/media` does not point at a deleted attachment. -- Inline text/document artifacts may store `content` directly in SQLite and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, accepting task-scoped artifact URIs under `artifacts/` and bridged image-attachment URIs under `attachments/`, or returns inline `content` with the persisted MIME type when no `uri` exists. +- Inline text/document artifacts may store `content` directly in PostgreSQL and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, accepting task-scoped artifact URIs under `artifacts/` and bridged image-attachment URIs under `attachments/`, or returns inline `content` with the persisted MIME type when no `uri` exists. - `getArtifact(id)` returns metadata by ID, `getArtifacts(taskId)` returns active-task artifacts newest-first, and `listArtifacts(...)` is the cross-agent query path with type/author/task/search filters and pagination. List reads hide artifacts whose parent task is soft-deleted while preserving task-less artifacts. - `updateArtifact(id, { title?, description?, content? })` powers the dashboard Artifacts view's in-place doc editing (`GET`/`PATCH /api/artifacts/:id`). Content edits are only allowed on inline-content rows (no `uri`); binary-backed rows accept metadata edits only, archived-task artifacts stay read-only, and successful updates emit `artifact:updated` for live gallery refresh. - `fn_artifact_register` accepts a local file `path` (in addition to inline `content`/`dataBase64`): the tool reads the file (50 MB cap), infers the MIME type from the extension when omitted, signature-validates image payloads (PNG/JPEG/GIF/WebP magic bytes, SVG text sniff), video payloads (mp4/mov `ftyp` box, WebM EBML header), and PDF payloads (`%PDF-` prefix), and persists the bytes through `registerArtifact()`'s managed storage path so the registry row keeps a servable URI after worktrees are cleaned up. Executor-lane registrations resolve relative paths against the task worktree and default `taskId` to the executing task. Every `path` is containment-checked before stat/read: the realpath-canonicalized file (symlinks and `../` segments resolved) must remain inside the session's `baseDir` or the OS temp directory; relative paths require a configured `baseDir`, and lanes without one (dashboard chat, no-task heartbeats) accept only absolute paths under the OS temp directory. HTML mockups register as `type="document"` + `mimeType="text/html"` (via `content` or `path`) and render as live sandboxed previews in the Artifacts view. @@ -101,7 +105,7 @@ Fusion runs a read-only task-ID integrity detector at startup and on demand to s The latest report is exposed in two operator-facing places: -- `GET /api/health` returns a `taskIdIntegrity` object with `status`, `checkedAt`, `anomalies`, and a `recommendedAction` string. When anomalies are present, the top-level health `status` becomes `"degraded"` even if the SQLite integrity check is still healthy. +- `GET /api/health` returns a `taskIdIntegrity` object with `status`, `checkedAt`, `anomalies`, and a `recommendedAction` string. Anomalies or an integrity-query `error` degrade the top-level health status; missing PostgreSQL layers and connectivity failures also fail closed rather than reporting the backend-mode healthy sentinel. - The dashboard renders a non-dismissible task-ID integrity banner for anomalous reports so the operator sees the issue in the same session. ### Operator playbook @@ -122,7 +126,7 @@ node scripts/audit-task-id-collisions.mjs [--project-root /path/to/project] The script checks for: - `task.json.history` timestamps older than the active DB row's `createdAt` -- task-title mismatches between SQLite and the first `#` heading in `PROMPT.md` +- task-title mismatches between PostgreSQL and the first `#` heading in `PROMPT.md` - task-title mismatches against the latest `Fusion-Task-Id` commit subject on `main` - active tasks that share an ID with an `archivedTasks` row @@ -148,7 +152,7 @@ Use this path for the confirmed FN-3909 mismatch (canonical UI-fix prompt/merge For any audit/forensic/reconciliation task that targets another task ID (for example FN-4194 reconciling FN-3909), source-of-truth locations are always at the project root: - On-disk task artifacts: `/.fusion/tasks/{ID}/` (`task.json`, `PROMPT.md`, `attachments/`, agent logs) -- Task database row: `/.fusion/fusion.db` (SQLite in WAL mode) +- Task database row: PostgreSQL `project.tasks` scoped by the project's `.fusion/project.json` identity Important execution nuance: @@ -170,7 +174,9 @@ Important execution nuance: - done tasks: prefer `mergeDetails.landedFiles` - in-progress/in-review (or legacy pre-FN-4646 tasks): fall back to `task.modifiedFiles` -## FTS5 task-index maintenance (FN-5943 / FN-5976) +## Legacy SQLite FTS5 task-index maintenance (historical, FN-5943 / FN-5976) + +This section records the pre-PostgreSQL design for migration archaeology. It is not an active runtime architecture or recommendation. - Live task search uses the `tasks_fts` external-content FTS5 table in `fusion.db`; the archive log uses a separate `archived_tasks_fts` table in `archive.db`. - `tasks_fts_au` is value-aware and column-scoped. Hot task mutations (`atomicWriteTaskJson` / `atomicWriteTaskJsonWithAudit`) now diff the current row against the incoming task and issue `UPDATE tasks SET , updatedAt = ? WHERE id = ?` instead of rewriting the full task row. Non-text churn (status, steps, leases, scheduler stamps) therefore skips the FTS trigger entirely because those UPDATEs omit the indexed text columns. @@ -217,7 +223,7 @@ Important execution nuance: - The attached-file idea still improves corruption isolation, but it would trade away the current same-file trigger-maintained index for a manual two-file sync architecture with weaker crash atomicity under WAL. - Revisit only if post-FN-5943 production evidence shows recurring `fusion.db`-coupled FTS corruption or materially persistent live-index bloat significant enough to justify a contentless/manual-sync redesign. Until then, keep the single-file external-content design and existing maintenance path. -## SQLite write-path lock recovery (FN-4042 / FN-4083) +## Legacy SQLite write-path lock recovery (historical, FN-4042 / FN-4083) - Every disk-backed SQLite connection that Fusion opens for project storage (`fusion.db`), the central registry (`fusion-central.db`), archives (`archive.db`), and worktree hydration explicitly sets `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` at connection open time before write work begins. - Project database transactions now distinguish read and write intent: @@ -239,7 +245,7 @@ Important execution nuance: - **Backend settings keys defined in `@fusion/core`:** **79** total - **Global settings:** 17 (`GlobalSettings`) - **Project settings:** 62 (`ProjectSettings`) -- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **47** (including migration-created tables) +- **Legacy SQLite tables in the audited pre-cutover project schema (`packages/core/src/db.ts`):** **47** (including migration-created tables; retained here as migration inventory) - **Issues identified:** **9** - High: 2 - Medium: 5 @@ -407,7 +413,7 @@ Additional backend notes: --- -### Backup pairing behavior (project + central DB) +### Legacy SQLite backup pairing behavior (migration/history only) Backups in `.fusion/backups/` now capture the project DB and (when present) the global central DB as a pair using the same timestamp/counter: - `fusion-(-N).db` (project) @@ -417,12 +423,12 @@ Backups in `.fusion/backups/` now capture the project DB and (when present) the Database Backup automation failures are surfaced with DB-qualified detail. Project backup failures include the project DB source path, backup target or backup directory when available, and the underlying cause; central DB sub-failures keep the project backup run successful but include `Central DB backup failed` plus central source/target/cause detail in the run output. -## 4) SQLite Tables Inventory (`packages/core/src/db.ts`) +## 4) Legacy SQLite Tables Inventory (`packages/core/src/db.ts`, migration reference) | Table | Purpose | |---|---| | `tasks` | Core task metadata and JSON-backed nested fields (priority, dependencies, steps, log, attachments, comments, model overrides, workflow results, merge details, assignment, mission linkage). | -| `branch_groups` | Durable shared-branch group records keyed by `BG-*` id with source linkage (`mission`/`planning`/`new-task`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. This SQLite row is the authority for grouped-task reads after restart; task `sourceMetadata.fusionBranchContext.groupId` values should point at real `BG-*` rows, and stale per-task references are cleared through `TaskStore.setTaskBranchGroup(taskId, null)` / `POST /api/branch-groups/assign` rather than raw SQLite edits. | +| `branch_groups` | Durable PostgreSQL shared-branch group records keyed by project plus `BG-*` id, with source linkage (`mission`/`planning`/`new-task`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. These rows are authoritative after restart; task `sourceMetadata.fusionBranchContext.groupId` values should point at real `BG-*` rows, and stale references are cleared through `TaskStore.setTaskBranchGroup(taskId, null)` / `POST /api/branch-groups/assign` rather than direct SQL edits. | | `mergeQueue` | Durable merge handoff queue keyed by `taskId`. Stores enqueue ordering (`enqueuedAt`, mirrored `priority`), single-owner lease state (`leasedBy`, `leasedAt`, `leaseExpiresAt`), and retry diagnostics (`attemptCount`, `lastError`). Leasing is priority-first + FIFO within priority, and expired leases are recoverable without incrementing attempts. FN-5242 adds the persistence/lease primitive; FN-5241 and FN-5243 wire executor enqueue + merger consumption. | FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor/self-healing path into `in-review` after execution finishes is `TaskStore.handoffToReview(...)`. That helper runs the column move, `mergeQueue` insert, and handoff audit fan-out inside one `BEGIN IMMEDIATE` transaction so observers never see `column = "in-review"` without the matching queue row. Direct `moveTask(taskId, "in-review")` writes remain allowed for explicit non-handoff/test paths but emit `task:handoff-invariant-violation` run-audit events unless the caller opts into the narrow allowlist flag. @@ -499,11 +505,11 @@ The `tasks.cumulativeActiveMs` and `tasks.executionCompletedAt` columns are the FNXC:AiSessionStore 2026-07-13-00:00: Deleting a Planning Mode session while its background generation was still in flight let the session silently reappear moments later. Root cause: `runGenerationWithTimeout`'s `Promise.race` (in `planning.ts`, and the equivalent wrappers in `subtask-breakdown.ts`/`mission-interview.ts`/`milestone-slice-interview.ts`) only stops the *caller* from awaiting the in-flight `session.agent.session.prompt()` call — it does not cancel it. A straggling `persistSession(...)`-style `upsert()` call landing after the row was deleted would silently re-INSERT it and re-broadcast `ai_session:updated`. */ -`AiSessionStore.delete()` / `deleteByIdAndType()` / the bulk `cleanupOld()` / `cleanupStaleSessions()` paths now record a delete tombstone (`id -> deletion timestamp`) alongside removing the row. `AiSessionStore.upsert()` checks that tombstone first: a write for an id deleted within the last `DELETE_TOMBSTONE_TTL_MS` (10 minutes — generously longer than any realistic straggling generation write) is dropped without touching SQLite and without emitting `ai_session:updated`. This closes the resurrection race for every `AiSessionType` producer that shares the store (`planning`, `subtask`, `mission_interview`, `milestone_interview`, `slice_interview`), not just the originally reported Planning Mode case. +`AiSessionStore.delete()` / `deleteByIdAndType()` / the bulk `cleanupOld()` / `cleanupStaleSessions()` paths now record a delete tombstone (`id -> deletion timestamp`) alongside removing the row. `AiSessionStore.upsert()` checks that tombstone first: a write for an id deleted within the last `DELETE_TOMBSTONE_TTL_MS` (10 minutes — generously longer than any realistic straggling generation write) is dropped without touching PostgreSQL and without emitting `ai_session:updated`. This closes the resurrection race for every `AiSessionType` producer that shares the store (`planning`, `subtask`, `mission_interview`, `milestone_interview`, `slice_interview`), not just the originally reported Planning Mode case. A normal delete with no in-flight generation, and a genuinely new session that reuses a brand-new distinct id, are both unaffected — the guard only applies to writes for the *exact* id that was just deleted. Tombstone entries are pruned lazily (on tombstone check) and piggyback pruning on the existing `cleanupStaleSessions()` cadence, so the in-memory tombstone map cannot grow unbounded on a long-running server. See `packages/dashboard/src/ai-session-store.ts` (`upsert()`, `isTombstoned()`, `pruneExpiredTombstones()`). -### Central SQLite Tables Inventory (`packages/core/src/central-db.ts`) +### Legacy Central SQLite Tables Inventory (`packages/core/src/central-db.ts`, migration reference) | Table | Purpose | |---|---| @@ -518,15 +524,15 @@ A normal delete with no in-flight generation, and a genuinely new session that r Invariant: after init, every declared column for covered tables exists regardless of `__meta.schemaVersion` whenever the fingerprint is stale or missing, preventing legacy drift from causing `no such column` regressions on newly added fields while keeping unchanged-schema opens fast. -### Project identity row (`__meta.projectIdentity`) +### Legacy project identity row (`__meta.projectIdentity`, migration reference) -Each project-scoped `.fusion/fusion.db` now stores the canonical central registry identity in `__meta.projectIdentity` as JSON: +Pre-cutover `.fusion/fusion.db` files stored the canonical central registry identity in `__meta.projectIdentity` as JSON: ```json { "id": "proj_0123456789abcdef", "createdAt": "2026-05-21T12:00:00.000Z", "firstSeenPath": "/abs/project/path" } ``` -This is written on first successful registration (and back-filled on later startup for older projects). If `~/.fusion/fusion-central.db` loses the row for that path, startup reads this identity and reattaches the same `projectId` instead of minting a new id. That preserves project-scoped rows keyed by `projectId` (`todo_lists`, `chat_sessions`, `project_insights`, etc.). +The PostgreSQL-era runtime writes `.fusion/project.json`. Startup reads this legacy SQLite identity only during migration/reattachment, then preserves the same `projectId` instead of minting a new one. --- @@ -592,8 +598,8 @@ This is written on first successful registration (and back-filled on later start 9. **Workflow steps still persisted in config JSON compatibility path (known in-progress work)** - **Severity:** Low - **Affected:** `config.settings/workflowSteps`, `db.ts` config table - - **Problem:** Workflow step storage is still tied to config blob structure; this is already being addressed by **FN-1201** (migration to dedicated SQLite table). - - **Recommended fix:** Continue and complete FN-1201; remove config-blob coupling after migration. + - **Problem (historical audit):** Workflow step storage was tied to config blob structure; **FN-1201** moved it to a dedicated table before the PostgreSQL cutover. + - **Recommended fix:** Preserve the dedicated PostgreSQL table and do not reintroduce config-blob coupling. --- @@ -651,7 +657,9 @@ This is written on first successful registration (and back-filled on later start - [x] SQLite table inventory included - [x] Known in-progress FN-1201 called out -## Per-Worktree DB Hydration +## Legacy per-worktree SQLite DB hydration (historical) + +The following section documents the retired SQLite runtime. PostgreSQL-backed worktrees do not create or hydrate authoritative `fusion.db` files. Each git worktree has its own gitignored `.fusion/` directory, so `.fusion/fusion.db` is local scratch state per worktree. That isolation created a cross-task lookup gap: executor prompts that query sibling/dependency rows directly from the worktree DB could see empty results. FN-3840 documented the manual `ATTACH`/`INSERT OR REPLACE` recovery, and FN-3832 was the breaking case that surfaced this in production. diff --git a/docs/task-management.md b/docs/task-management.md index a3853dfd46..817a232876 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -423,7 +423,7 @@ When a task was created to resolve a temporary failure state in another task (fo Use supported TaskStore/API paths to reconcile safely: -- Remove/replace stale dependencies through task update APIs (do not hand-edit `task.json`/SQLite) +- Remove/replace stale dependencies through task update APIs (do not hand-edit `task.json` or PostgreSQL rows) - Add a single comment/log entry explaining why the dependency changed - Keep downstream blockers coherent (only tasks that still truly depend on unfinished work should remain blocked) @@ -433,7 +433,7 @@ Auto-merge recovery follow-up creation is deduplicated: Fusion creates at most o ### Landed-task state reconciliation (maintenance) -If a task already shipped (`column: done`) but still carries transient failure metadata (`status: failed`, `error`, `worktree`, `blockedBy`, recovery retry fields), reconcile through supported TaskStore/API paths so SQLite and task JSON stay in sync. +If a task already shipped (`column: done`) but still carries transient failure metadata (`status: failed`, `error`, `worktree`, `blockedBy`, recovery retry fields), reconcile through supported TaskStore/API paths so PostgreSQL and task JSON compatibility artifacts stay in sync. Recommended pattern: - Audit first (dry-run) for contradictory `done` + transient-failure state. @@ -441,7 +441,7 @@ Recommended pattern: - Add one durable reconciliation log entry explaining why stale transient fields were cleared (avoid duplicating historical failure logs). - Re-audit after apply and resolve or explicitly disposition any related stale follow-up tasks. -Do **not** patch `.fusion/fusion.db` directly without synchronizing `.fusion/tasks/*/task.json` through a supported store-backed path. +Do **not** patch PostgreSQL rows or compatibility `task.json` files directly; use a supported store/API path so both representations remain synchronized. ## Branch conflict handling @@ -645,7 +645,7 @@ Behavior: ### Cleanup behavior -- Archived entries are persisted as compact archive snapshots in `archive.db`; legacy in-main-DB `archivedTasks` rows and older `.fusion/archive.jsonl` references may still appear in historical data/docs. +- Archived entries are persisted as compact snapshots in PostgreSQL cold-storage tables; legacy `archive.db`, in-main-DB `archivedTasks`, and older `.fusion/archive.jsonl` data remain migration inputs only. - Task directory (`task.json`, `PROMPT.md`, `agent.log`, attachments) can be removed ### Compact archive entry format @@ -679,7 +679,7 @@ Archive entries preserve key metadata needed for restoration, including: If you suspect **historical overwrites from pre-FN-4044 builds**, inspect surviving evidence in this order: -1. `archive.db` / archived task snapshots for the missing ID +1. PostgreSQL archived-task snapshots for the missing ID (then legacy `archive.db` only when auditing unmigrated data) 2. `.fusion/tasks//task.json.bak`, `PROMPT.md`, attachments, and any surviving worktree branch named for the task 3. agent run logs / task documents / activity log entries that still mention the original ID 4. git commits whose subject/body references the original task ID but no longer matches the current task metadata @@ -929,7 +929,7 @@ Use `noCommitsExpected: true` for tasks where the deliverable is a decision/repo - Review Level 1 coordination/routing tasks that are board-only, explicitly say not to change source, and scope only task documents/metadata can also complete without commits even if older prompts omitted the explicit flag. This fallback is intentionally narrow and exists to recover plan-only coordination work; it does not bypass wrong-worktree or wrong-branch checks. - Ambiguous/forked tasks (e.g. "Investigate..." or "Investigate and fix if needed") leave it unset by default. - Implementation, feature, bug-fix, source-docs, test, config, or broad investigation tasks still require commits unless they have an explicit and valid no-commit contract. -- If a legacy coordination task is stuck with `fn_task_done refused: no_commits`, prefer setting/verifying `noCommitsExpected` and re-running normal no-op finalization rather than editing `.fusion/fusion.db` directly. +- If a legacy coordination task is stuck with `fn_task_done refused: no_commits`, prefer setting/verifying `noCommitsExpected` and re-running normal no-op finalization rather than editing storage directly. - You can manually set/clear it in Task Detail via **No commits expected (decision-only task)**. - Task cards show a **decision-only** badge when enabled. - Finalization still uses the existing no-op review/merge path (`mergeDetails.noOpMerge: true`, `mergeConfirmed: true`); no synthetic merge strategy values are introduced. diff --git a/docs/todo-view.md b/docs/todo-view.md index 98bff237ad..cc95ca3879 100644 --- a/docs/todo-view.md +++ b/docs/todo-view.md @@ -121,7 +121,7 @@ When omitted, Todo APIs operate against the default/local project scope (`""` pr ## Storage linkage -Todo data is persisted in the project SQLite database (`.fusion/fusion.db`) via: +Todo data is persisted in the project PostgreSQL schema, isolated by `project_id`, via: - `todo_lists` - `todo_items` diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index a62db51d69..f1b0d29720 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1436,20 +1436,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: try { const { loaded, errors } = await pluginLoader.loadAllPlugins(); logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins"); - - const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); - if (schemaHooks.length > 0) { - try { - /* FNXC:PluginPostgresSchema 2026-07-14-17:30: Dashboard startup materializes runtime-loaded plugin schemas through the backend-aware TaskStore contract instead of skipping PostgreSQL hooks. */ - await store.runPluginSchemaInits(schemaHooks); - } catch (err) { - logSink.log( - `Schema initialization failed: ${err instanceof Error ? err.message : err}`, - "plugins", - ); - throw err; - } - } + /* FNXC:PluginPostgresSchema 2026-07-14-23:31: PluginLoader executes each schema contract before onLoad; dashboard startup must not replay those PostgreSQL transactions after loadAllPlugins. */ } catch (err) { logSink.log( `Failed to load plugins: ${err instanceof Error ? err.message : err}`, diff --git a/packages/cli/src/commands/db.ts b/packages/cli/src/commands/db.ts index 710d2f13b3..d6be1b261f 100644 --- a/packages/cli/src/commands/db.ts +++ b/packages/cli/src/commands/db.ts @@ -278,6 +278,7 @@ export async function runDbMigrate( report = await migrateSqliteToPostgres(connections.migration, presentSources, { dryRun, projectId: registeredProjectId, + projectPath: projectRoot, migrationKey: `project:${registeredProjectId}`, deferCompletion: true, onProgress: (event) => { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0e4aa4f7d2..9231f0ca1b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -614,30 +614,19 @@ export async function runServe( FNXC:PluginPostgresSchema 2026-07-14-21:48: Optional plugin module-load failures remain nonfatal for serve compatibility. A schema initialization failure from a loaded plugin is instead a fatal storage-integrity error and must escape startup rather than being swallowed by the module-load catch. */ - let pluginsLoaded = false; try { const { loaded, errors } = await pluginLoader.loadAllPlugins(); console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`); - pluginsLoaded = true; } catch (err) { console.error( `[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}` ); } - if (pluginsLoaded) { - const schemaHooks = pluginLoader.getPluginSchemaInitHooks?.() ?? []; - if (schemaHooks.length > 0) { - try { - await store.runPluginSchemaInits(schemaHooks); - } catch (err) { - console.error( - `[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`, - ); - throw err; - } - } - } + /* + FNXC:PluginPostgresSchema 2026-07-14-23:31: + PluginLoader owns schema execution before each plugin's onLoad hook. Hosts must not replay the collected contracts after loadAllPlugins because duplicate PostgreSQL transactions and advisory-lock acquisition add startup contention without strengthening the fail-closed contract. + */ // Get subsystems from the primary engine for the HTTP layer const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor(); diff --git a/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts b/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts index 672d68c3ac..793f8c9ed5 100644 --- a/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts +++ b/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts @@ -3,11 +3,31 @@ import { readFileSync, readdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { + cePluginSchemaInit, + cliPressPluginSchemaInit, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, + reportsPluginSchemaInit, + roadmapPluginSchemaInit, runLoadedPluginSchemaInitHooks, validatePluginPostgresSchema, } from "../../postgres/plugin-schema-hook.js"; +function transactionalDb(execute: ReturnType) { + return { + execute, + transaction: vi.fn(async (callback: (tx: { execute: typeof execute }) => Promise) => ( + callback({ execute }) + )), + }; +} + +function executedSql(execute: ReturnType): string { + return execute.mock.calls + .map((call) => (call[0] as { queryChunks?: Array<{ value: string[] }> }).queryChunks + ?.flatMap((chunk) => chunk.value).join("") ?? "") + .join("\n"); +} + describe("PostgreSQL plugin schema registry", () => { /* FNXC:PluginPostgresSchema 2026-07-14-18:45: @@ -35,7 +55,7 @@ describe("PostgreSQL plugin schema registry", () => { it("runs the registered PostgreSQL hook instead of the legacy callback", async () => { const execute = vi.fn().mockResolvedValue([]); const legacy = vi.fn(); - await runLoadedPluginSchemaInitHooks({ execute } as never, [{ + await runLoadedPluginSchemaInitHooks(transactionalDb(execute) as never, [{ pluginId: "fusion-plugin-even-realities-glasses", hook: legacy, }]); @@ -64,12 +84,12 @@ describe("PostgreSQL plugin schema registry", () => { ], } as const; - await runLoadedPluginSchemaInitHooks({ execute } as never, [{ + await runLoadedPluginSchemaInitHooks(transactionalDb(execute) as never, [{ pluginId: "external-fixture", postgresSchema: definition, }]); - expect(execute).toHaveBeenCalledTimes(3); + expect(execute).toHaveBeenCalledTimes(4); }); it("rejects unscoped or privileged third-party DDL", () => { @@ -89,4 +109,208 @@ describe("PostgreSQL plugin schema registry", () => { statements: ["DROP TABLE project.tasks"], })).toThrow("project schema"); }); + + /* + FNXC:PluginPostgresContract 2026-07-14-22:42: + The declarative PostgreSQL contract may evolve ordinary plugin columns, while Fusion exclusively owns tenant identity, RLS, table identity, keys, and grants. Reject every ALTER shape that could weaken that boundary before a privileged transaction begins. + */ + it.each([ + "ALTER TABLE project.bad_rows DISABLE ROW LEVEL SECURITY", + "ALTER TABLE project.bad_rows DROP COLUMN project_id", + "ALTER TABLE project.bad_rows RENAME COLUMN project_id TO tenant_id", + "ALTER TABLE project.bad_rows ALTER COLUMN project_id SET DEFAULT 'stolen'", + "ALTER TABLE project.bad_rows DROP CONSTRAINT bad_rows_pkey", + "ALTER TABLE project.bad_rows OWNER TO postgres", + ])("rejects privileged third-party ALTER TABLE: %s", (statement) => { + expect(() => validatePluginPostgresSchema("external-fixture", { + version: 1, + tablePrefix: "bad_", + statements: [statement], + })).toThrow("non-project_id data columns"); + }); + + it("reinstalls isolation for tables changed only by a safe ALTER", async () => { + const execute = vi.fn().mockResolvedValue([]); + + await runLoadedPluginSchemaInitHooks(transactionalDb(execute) as never, [{ + pluginId: "external-fixture", + postgresSchema: { + version: 2, + tablePrefix: "external_fixture_", + statements: ["ALTER TABLE project.external_fixture_rows ADD COLUMN IF NOT EXISTS notes text"], + }, + }]); + + expect(execute).toHaveBeenCalledTimes(3); + const envelope = (execute.mock.calls[2]?.[0] as { queryChunks: Array<{ value: string[] }> }) + .queryChunks.flatMap((chunk) => chunk.value).join(""); + expect(envelope).toContain('FORCE ROW LEVEL SECURITY'); + expect(envelope).toContain('project."external_fixture_rows"'); + }); + + it("rolls back the whole contract when a later statement fails", async () => { + const committed: string[] = []; + const db = { + transaction: vi.fn(async (callback: (tx: { execute: (query: unknown) => Promise }) => Promise) => { + const pending: string[] = []; + const tx = { + execute: async (query: unknown) => { + const text = (query as { queryChunks: Array<{ value: string[] }> }).queryChunks + .flatMap((chunk) => chunk.value).join(""); + pending.push(text); + if (text.includes("ALTER COLUMN notes SET NOT NULL")) throw new Error("fixture DDL failure"); + return []; + }, + }; + const result = await callback(tx); + committed.push(...pending); + return result; + }), + }; + + await expect(runLoadedPluginSchemaInitHooks(db as never, [{ + pluginId: "external-fixture", + postgresSchema: { + version: 2, + tablePrefix: "external_fixture_", + statements: [ + "CREATE TABLE IF NOT EXISTS project.external_fixture_rows (project_id text NOT NULL, id text NOT NULL, notes text, PRIMARY KEY (project_id, id))", + "ALTER TABLE project.external_fixture_rows ALTER COLUMN notes SET NOT NULL", + ], + }, + }])).rejects.toThrow("fixture DDL failure"); + expect(committed).toEqual([]); + }); + + it("serializes concurrent contracts with the schema-applier advisory lock", async () => { + const events: string[] = []; + let release: (() => void) | undefined; + let held = false; + const waiters: Array<() => void> = []; + const acquire = async () => { + if (held) await new Promise((resolve) => waiters.push(resolve)); + held = true; + release = () => { + held = false; + waiters.shift()?.(); + }; + }; + const db = { + transaction: async (callback: (tx: { execute: (query: unknown) => Promise }) => Promise) => { + let ownsLock = false; + try { + return await callback({ + execute: async (query: unknown) => { + const text = (query as { queryChunks: Array<{ value: string[] }> }).queryChunks + .flatMap((chunk) => chunk.value).join(""); + if (text.includes("pg_advisory_xact_lock")) { + await acquire(); + ownsLock = true; + events.push("lock"); + } else { + const table = text.match(/external_fixture_(one|two)/)?.[1] ?? "unknown"; + events.push(table); + await Promise.resolve(); + } + return []; + }, + }); + } finally { + if (ownsLock) release?.(); + } + }, + }; + const contract = (suffix: "one" | "two") => runLoadedPluginSchemaInitHooks(db as never, [{ + pluginId: `fixture-${suffix}`, + postgresSchema: { + version: 1, + tablePrefix: "external_fixture_", + statements: [`CREATE TABLE IF NOT EXISTS project.external_fixture_${suffix} (project_id text NOT NULL, id text NOT NULL, PRIMARY KEY (project_id, id))`], + }, + }]); + + await Promise.all([contract("one"), contract("two")]); + expect(events).toEqual(["lock", "one", "one", "lock", "two", "two"]); + }); + + it("repairs Roadmap ownership outside legacy foreign keys and restores composite relationships", async () => { + const execute = vi.fn().mockResolvedValue([]); + await roadmapPluginSchemaInit.init({ execute } as never); + const ddl = executedSql(execute); + + const dropMilestoneFk = ddl.indexOf("DROP CONSTRAINT IF EXISTS roadmap_milestones_roadmap_id_fkey"); + const ownershipBackfill = ddl.indexOf("UPDATE project.roadmap_milestones milestone"); + const compositeMilestoneFk = ddl.lastIndexOf("FOREIGN KEY (project_id, roadmap_id)"); + const validation = ddl.indexOf("RAISE EXCEPTION 'Roadmap PostgreSQL upgrade found"); + expect(dropMilestoneFk).toBeGreaterThanOrEqual(0); + expect(dropMilestoneFk).toBeLessThan(ownershipBackfill); + expect(compositeMilestoneFk).toBeGreaterThan(validation); + expect(ddl).toContain("PRIMARY KEY (project_id, id)"); + expect(ddl).toContain("FOREIGN KEY (project_id, milestone_id)"); + expect(ddl).toContain("roadmap.project_id = milestone.project_id"); + expect(ddl).toContain("roadmap.id = milestone.roadmap_id"); + expect(ddl).toContain("milestone.project_id = feature.project_id"); + expect(ddl).toContain("milestone.id = feature.milestone_id"); + expect(ddl).not.toContain("JOIN project.roadmaps roadmap ON roadmap.id = milestone.roadmap_id"); + }); + + /* + FNXC:PluginIndexIsolation 2026-07-14-23:55: + Project-scoped plugin readers need tenant-leading lookup indexes across every status, relationship, and time query surface. Assert the generated reconciliation DDL rather than a second hand-maintained runtime inventory. + */ + it.each([ + [cePluginSchemaInit, [ + 'project.ce_sessions(project_id, status, updated_at DESC, id)', + 'project.ce_sessions(project_id, stage, created_at DESC, id)', + 'project.ce_pipeline_links(project_id, ce_pipeline_id, created_at DESC, id)', + 'project.ce_pipeline_state(project_id, status, updated_at DESC, ce_pipeline_id)', + 'project.ce_pipeline_sync_queue(project_id, processed_at, enqueued_at, id)', + ]], + [reportsPluginSchemaInit, [ + 'project.reports(project_id, cadence, created_at DESC, id)', + 'project.reports(project_id, status, updated_at DESC, id)', + 'project.reports(project_id, period_start, period_end, id)', + ]], + [cliPressPluginSchemaInit, [ + 'project.cli_press_cli_specs(project_id, service_id, created_at, id)', + 'project.cli_press_artifacts(project_id, cli_spec_id, created_at, id)', + 'project.cli_press_credentials(project_id, service_id, created_at, id)', + 'project.cli_press_service_settings(project_id, service_id, created_at, id)', + ]], + ] as const)("creates project_id-leading bundled-plugin secondary indexes", async (hook, definitions) => { + const execute = vi.fn().mockResolvedValue([]); + await hook.init({ execute } as never); + const indexDdl = executedSql(execute); + for (const definition of definitions) expect(indexDdl).toContain(definition); + }); + + /* + FNXC:PluginLegacyOwnership 2026-07-14-21:41: + A migration connection is intentionally not bound to fusion.project_id. Bundled plugin upgrades must recover pre-project rows only from an unambiguous central.projects singleton and must reject zero/multiple candidates instead of silently making preserved data invisible behind __legacy_unscoped__. + */ + it.each([ + ["Roadmap", roadmapPluginSchemaInit, "$roadmap_upgrade$"], + ["Compound Engineering", cePluginSchemaInit, "$ce_pipeline_upgrade$"], + ["Reports", reportsPluginSchemaInit, "$reports_upgrade$"], + ["CLI Printing Press", cliPressPluginSchemaInit, "$cli_press_upgrade$"], + ] as const)("fails closed when %s legacy ownership is ambiguous", async (_name, hook, blockTag) => { + const execute = vi.fn().mockResolvedValue([]); + + await hook.init({ execute } as never); + + expect(execute).toHaveBeenCalledTimes(5); + const query = executedSql(execute); + const upgradeStart = query.indexOf(`DO ${blockTag}`); + const upgradeEnd = query.indexOf(blockTag, upgradeStart + blockTag.length); + const upgrade = query.slice(upgradeStart, upgradeEnd + blockTag.length); + + expect(upgradeStart).toBeGreaterThanOrEqual(0); + expect(upgrade).toContain("FROM central.projects"); + expect(upgrade).toContain("registered_project_count <> 1"); + expect(upgrade).toContain("SET project_id = singleton_project_id"); + expect(upgrade).toContain("RAISE EXCEPTION"); + expect(upgrade).toContain("project_id IN ('', '__legacy_unscoped__')"); + expect(upgrade).not.toContain("SET project_id = '__legacy_unscoped__'"); + expect(upgrade).not.toContain("current_setting('fusion.project_id'"); + }); }); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 9a72994159..ed0da1f01c 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -30,6 +30,9 @@ import { applySchemaBaseline, getAppliedMigrations, SCHEMA_BASELINE_VERSION, + cePluginSchemaInit, + cliPressPluginSchemaInit, + reportsPluginSchemaInit, roadmapPluginSchemaInit, } from "../../postgres/index.js"; import { @@ -1416,6 +1419,79 @@ pgDescribe("schema-applier: VAL-SCHEMA-007 plugin-owned tables materialize via s expect(rows).toEqual([{ rls: true, forced: true }]); }); + it("preserves bundled plugin constraint and index OIDs on steady-state reapply", async () => { + ctx = await setupFreshDb(); + await ctx.db.execute(sql.raw(` + CREATE SCHEMA project; + CREATE SCHEMA central; + CREATE TABLE central.projects (id text PRIMARY KEY); + `)); + const hooks = [ + roadmapPluginSchemaInit, + cePluginSchemaInit, + reportsPluginSchemaInit, + cliPressPluginSchemaInit, + ]; + for (const hook of hooks) await hook.init(ctx.db); + + /* + FNXC:PluginSchemaPerformance 2026-07-14-23:40: + Reapplying the PostgreSQL baseline is a steady-state validation path, not a reason to replace bundled-plugin keys and indexes. Stable catalog OIDs prove the hooks avoided destructive DROP/ADD churn while retaining the same schema objects. + */ + const catalogObjects = async () => (await ctx!.db.execute(sql` + SELECT 'constraint' AS kind, conname AS name, oid::text AS oid, pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE connamespace = 'project'::regnamespace + AND conname IN ( + 'roadmaps_pkey', 'roadmap_milestones_pkey', 'roadmap_features_pkey', + 'roadmap_milestones_roadmap_id_fkey', 'roadmap_features_milestone_id_fkey', + 'ce_pipeline_links_pkey', 'ce_pipeline_state_pkey', 'ce_pipeline_sync_queue_pkey', + 'reports_pkey', + 'cli_press_services_pkey', 'uq_cli_press_services_project_slug', + 'cli_press_cli_specs_pkey', 'uq_cli_press_specs_service_name', 'cli_press_cli_specs_service_id_fkey', + 'cli_press_artifacts_pkey', 'cli_press_artifacts_cli_spec_id_fkey', + 'cli_press_credentials_pkey', 'uq_cli_press_credentials_service_name', 'cli_press_credentials_service_id_fkey', + 'cli_press_service_settings_pkey', 'uq_cli_press_settings_service_key_scope', 'cli_press_service_settings_service_id_fkey' + ) + UNION ALL + SELECT 'index' AS kind, c.relname AS name, c.oid::text AS oid, pg_get_indexdef(c.oid) AS definition + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'project' + AND c.relname IN ( + 'idxRoadmapMilestonesRoadmapOrder', + 'idxRoadmapFeaturesMilestoneOrder', + 'idxRoadmapsProject', + 'idxRoadmapMilestonesProject', + 'idxRoadmapFeaturesProject', + 'idxCeSessionsStatusUpdated', + 'idxCeSessionsStageCreated', + 'idxCeSessionsProject', + 'idxCePipelineLinksPipeline', + 'idxCePipelineLinksTask', + 'idxCePipelineStateStatus', + 'idxCePipelineSyncQueuePending', + 'idxCePipelineSyncQueuePipeline', + 'idxReportsCadenceCreated', + 'idxReportsStatusUpdated', + 'idxReportsPeriod', + 'idx_cli_press_specs_service', + 'idx_cli_press_artifacts_spec', + 'idx_cli_press_credentials_service', + 'idx_cli_press_settings_service' + ) + ORDER BY kind, name + `)) as unknown as Array<{ kind: string; name: string; oid: string; definition: string }>; + + const before = await catalogObjects(); + expect(before).toHaveLength(42); + for (const index of before.filter((entry) => entry.kind === "index")) { + expect(index.definition, index.name).toMatch(/USING btree \(project_id,/); + } + for (const hook of hooks) await hook.init(ctx.db); + expect(await catalogObjects()).toEqual(before); + }); + it("roadmap FK cascade: deleting a roadmap removes its milestones and features", async () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [roadmapPluginInitHook] }); diff --git a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts index 8b0fe52486..776ca202bc 100644 --- a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts +++ b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts @@ -31,10 +31,11 @@ import { sql } from "drizzle-orm"; import { execSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { DatabaseSync } from "../../sqlite-adapter.js"; import { formatMigrationProgress, + migrateLegacyProjectPluginRows, migrateSqliteToPostgres, toSnakeCase, type MigrationProgressEvent, @@ -145,6 +146,62 @@ CREATE TABLE IF NOT EXISTS config ( ); `; +const LEGACY_PLUGINS_SQLITE_DDL = ` +CREATE TABLE plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + description TEXT, + author TEXT, + homepage TEXT, + path TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + state TEXT NOT NULL DEFAULT 'installed', + settings TEXT DEFAULT '{}', + settingsSchema TEXT, + error TEXT, + dependencies TEXT DEFAULT '[]', + aiScanOnLoad INTEGER NOT NULL DEFAULT 0, + lastSecurityScan TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); +`; + +function insertLegacyPlugin( + db: DatabaseSync, + input: { + id: string; + name: string; + version: string; + enabled: number; + state: string; + error?: string | null; + updatedAt: string; + }, +): void { + db.prepare(` + INSERT INTO plugins + (id, name, version, description, author, homepage, path, enabled, state, + settings, settingsSchema, error, dependencies, aiScanOnLoad, + lastSecurityScan, createdAt, updatedAt) + VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) + `).run( + input.id, + input.name, + input.version, + `/plugins/${input.id}`, + input.enabled, + input.state, + JSON.stringify({ source: input.name }), + JSON.stringify({ source: { type: "string" } }), + input.error ?? null, + JSON.stringify([`${input.id}-dependency`]), + "2026-01-01T00:00:00.000Z", + input.updatedAt, + ); +} + /* FNXC:PostgresMigration 2026-07-13-20:30: Legacy camelCase-named table. Older SQLite tables are camelCase (activityLog, @@ -1009,6 +1066,165 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { expect(concurrency).toEqual([{ global_max_concurrent: 10, updated_at: "2026-06-02" }]); }); + /* + FNXC:PluginLegacyMigration 2026-07-14-22:50: + PostgreSQL cutover must split each retained project plugin row into one newer-wins global installation and an independent project-path state. The same plugin ID may be enabled in one project and disabled in another; repeated migration and older SQLite backups must never overwrite newer central operator changes. + */ + it("redirects legacy plugins into idempotent global installs and per-project states", async () => { + const projectA = resolve(join(ctx!.fusionDir, "project-a")); + const projectB = resolve(join(ctx!.fusionDir, "project-b")); + const sqliteA = join(ctx!.fusionDir, "plugins-a.db"); + const sqliteB = join(ctx!.fusionDir, "plugins-b.db"); + for (const sqlitePath of [sqliteA, sqliteB]) { + const legacy = new DatabaseSync(sqlitePath); + legacy.exec(LEGACY_PLUGINS_SQLITE_DDL); + if (sqlitePath === sqliteA) { + insertLegacyPlugin(legacy, { + id: "shared-plugin", + name: "Shared from A", + version: "1.0.0", + enabled: 1, + state: "started", + updatedAt: "2026-02-01T00:00:00.000Z", + }); + insertLegacyPlugin(legacy, { + id: "central-wins", + name: "Old local metadata", + version: "1.0.0", + enabled: 0, + state: "stopped", + error: "old local error", + updatedAt: "2026-02-01T00:00:00.000Z", + }); + } else { + insertLegacyPlugin(legacy, { + id: "shared-plugin", + name: "Shared from B", + version: "2.0.0", + enabled: 0, + state: "stopped", + error: "disabled in B", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + } + legacy.close(); + } + + await applySchemaBaseline(ctx!.db); + await ctx!.db.execute(sql` + INSERT INTO central.plugin_installs + (id, name, version, path, settings, dependencies, ai_scan_on_load, created_at, updated_at) + VALUES + ('central-wins', 'New central metadata', '9.0.0', '/central/plugin', '{}'::jsonb, '[]'::jsonb, 0, + '2026-01-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z') + `); + await ctx!.db.execute(sql` + INSERT INTO central.project_plugin_states + (project_path, plugin_id, enabled, state, error, created_at, updated_at) + VALUES + (${projectA}, 'central-wins', 1, 'started', NULL, + '2026-01-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z') + `); + + const reportA = await migrateTest( + ctx!.db, + [{ sqlitePath: sqliteA, pgSchema: "project", projectPath: projectA }], + { projectId: "plugin-project-a", migrationKey: "plugin-project-a", projectPath: projectA }, + ); + const reportB = await migrateTest( + ctx!.db, + [{ sqlitePath: sqliteB, pgSchema: "project", projectPath: projectB }], + { projectId: "plugin-project-b", migrationKey: "plugin-project-b", projectPath: projectB }, + ); + await migrateTest( + ctx!.db, + [{ sqlitePath: sqliteA, pgSchema: "project", projectPath: projectA }], + { projectId: "plugin-project-a", migrationKey: "plugin-project-a", projectPath: projectA }, + ); + + for (const report of [reportA, reportB]) { + expect(report.tables).toContainEqual(expect.objectContaining({ + table: "plugins", + verified: true, + skipped: true, + skipReason: "redirected to central plugin registry and project state", + })); + } + const compatibilityRows = await ctx!.db.execute(sql`SELECT id FROM project.plugins`); + expect(compatibilityRows).toHaveLength(0); + + const installs = await ctx!.db.execute(sql` + SELECT id, name, version, updated_at FROM central.plugin_installs ORDER BY id + `) as unknown as Array<{ id: string; name: string; version: string; updated_at: string }>; + expect(installs).toEqual([ + { id: "central-wins", name: "New central metadata", version: "9.0.0", updated_at: "2026-09-01T00:00:00.000Z" }, + { id: "shared-plugin", name: "Shared from B", version: "2.0.0", updated_at: "2026-03-01T00:00:00.000Z" }, + ]); + + const states = await ctx!.db.execute(sql` + SELECT project_path, plugin_id, enabled, state, error, updated_at + FROM central.project_plugin_states + ORDER BY project_path, plugin_id + `) as unknown as Array<{ + project_path: string; + plugin_id: string; + enabled: number; + state: string; + error: string | null; + updated_at: string; + }>; + expect(states).toEqual([ + { project_path: projectA, plugin_id: "central-wins", enabled: 1, state: "started", error: null, updated_at: "2026-09-01T00:00:00.000Z" }, + { project_path: projectA, plugin_id: "shared-plugin", enabled: 1, state: "started", error: null, updated_at: "2026-02-01T00:00:00.000Z" }, + { project_path: projectB, plugin_id: "shared-plugin", enabled: 0, state: "stopped", error: "disabled in B", updated_at: "2026-03-01T00:00:00.000Z" }, + ]); + + /* + FNXC:PluginLegacyMigration 2026-07-14-23:51: + After the first successful bridge, retained SQLite cannot regain authority even if its timestamps are edited to look newer. Backend restarts consult the durable project marker and preserve PostgreSQL operator state. + */ + const retained = new DatabaseSync(sqliteA); + retained.prepare(`UPDATE plugins SET name = ?, enabled = ?, updatedAt = ? WHERE id = ?`).run( + "Retained SQLite must not win", + 0, + "2027-01-01T00:00:00.000Z", + "shared-plugin", + ); + retained.close(); + await migrateLegacyProjectPluginRows(ctx!.db, sqliteA, projectA); + const preserved = (await ctx!.db.execute(sql` + SELECT install.name, state.enabled + FROM central.plugin_installs install + JOIN central.project_plugin_states state ON state.plugin_id = install.id + WHERE install.id = 'shared-plugin' AND state.project_path = ${projectA} + `)) as unknown as Array<{ name: string; enabled: number }>; + expect(preserved).toEqual([{ name: "Shared from B", enabled: 1 }]); + }); + + it("treats missing SQLite files and databases without plugins as a no-op", async () => { + await applySchemaBaseline(ctx!.db); + await expect(migrateLegacyProjectPluginRows( + ctx!.db, + join(ctx!.fusionDir, "missing.db"), + join(ctx!.fusionDir, "project-missing"), + )).resolves.toBeUndefined(); + + const sqlitePath = join(ctx!.fusionDir, "without-plugins.db"); + const legacy = new DatabaseSync(sqlitePath); + legacy.exec(`CREATE TABLE config (id INTEGER PRIMARY KEY, settings TEXT)`); + legacy.close(); + await expect(migrateLegacyProjectPluginRows( + ctx!.db, + sqlitePath, + join(ctx!.fusionDir, "project-empty"), + )).resolves.toBeUndefined(); + + const installs = await ctx!.db.execute(sql`SELECT id FROM central.plugin_installs`); + const states = await ctx!.db.execute(sql`SELECT plugin_id FROM central.project_plugin_states`); + expect(installs).toHaveLength(0); + expect(states).toHaveLength(0); + }); + /* FNXC:AutomationIsolation 2026-07-13-22:37: Legacy project databases do not carry project_id on automation rows. Migration must inject the resolved registry identity before verification so bound automation stores and cron runners see only their project's schedules, including when legacy automation IDs overlap. @@ -1543,12 +1759,28 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { expect(tasks.sourceRows).toBe(2); expect(tasks.skipped).toBe(true); - // PostgreSQL target should have ZERO rows (baseline applied but no data copied). - const pgTasks = (await ctx!.db.execute(sql`SELECT COUNT(*)::int AS n FROM project.tasks`)) as unknown as Array<{ n: number }>; - expect(pgTasks[0].n).toBe(0); - - const pgSecrets = (await ctx!.db.execute(sql`SELECT COUNT(*)::int AS n FROM project.secrets`)) as unknown as Array<{ n: number }>; - expect(pgSecrets[0].n).toBe(0); + /* + FNXC:PostgresMigration 2026-07-14-23:47: + VAL-MIGRATE-005 applies to catalog state as well as copied rows. A preview against a pristine external target must leave no schemas, tables, or migration marker behind after it reports the plan. + */ + const catalog = (await ctx!.db.execute(sql` + SELECT + to_regnamespace('project')::text AS project_schema, + to_regclass('project.tasks')::text AS tasks_table, + to_regclass('project.secrets')::text AS secrets_table, + to_regclass('public.fusion_sqlite_migrations')::text AS migration_table + `)) as unknown as Array<{ + project_schema: string | null; + tasks_table: string | null; + secrets_table: string | null; + migration_table: string | null; + }>; + expect(catalog).toEqual([{ + project_schema: null, + tasks_table: null, + secrets_table: null, + migration_table: null, + }]); // No sequences should have been bumped in dry-run. expect(report.sequenceBumps).toHaveLength(0); diff --git a/packages/core/src/plugin-store.ts b/packages/core/src/plugin-store.ts index 2dbbb9a706..893b0c1465 100644 --- a/packages/core/src/plugin-store.ts +++ b/packages/core/src/plugin-store.ts @@ -193,13 +193,23 @@ export class PluginStore extends EventEmitter { /** * FNXC:SqliteFinalRemoval 2026-06-26-10:10: - * In backend mode (asyncLayer injected), skip all SQLite construction and - * the legacy migration sweep. The PostgreSQL schema baseline already covers - * these. The per-project plugin state rows are created on-demand by the - * async register/enable/disable helpers. + * In backend mode (asyncLayer injected), never construct operational SQLite + * stores. A narrowly scoped, read-only bridge may inspect retained plugin + * rows once behind a durable PostgreSQL marker; all subsequent install and + * project-state authority remains in PostgreSQL. */ async init(): Promise { if (this.backendMode) { + /* + FNXC:PluginLegacyMigration 2026-07-14-22:50: + PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states. The retained-SQLite bridge runs once per project behind a durable PostgreSQL marker so projects cut over before this bridge existed recover their state without making fusion.db a recurring runtime authority. + */ + const { migrateLegacyProjectPluginRows } = await import("./postgres/sqlite-migrator.js"); + await migrateLegacyProjectPluginRows( + this.asyncLayer!.db, + join(this.rootDir, ".fusion", "fusion.db"), + this.normalizedProjectPath, + ); return; } const _ = this.localDb; diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index e3721ec7b6..4e1b455c38 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -319,13 +319,18 @@ export type PluginOnSchemaInit = (db: Database) => Promise | void; * the host's privileged migration connection. Fusion validates this immutable * plan before onLoad and executes it through a short-lived migration-only * capability, keeping ordinary plugin runtime code on the forced-RLS role. + * + * FNXC:PluginPostgresContract 2026-07-14-22:42: + * ALTER TABLE is intentionally limited to adding ordinary columns or setting + * their defaults/nullability. Fusion alone owns project_id, keys, RLS, + * policies, triggers, grants, ownership, and table identity. */ export interface PluginPostgresSchemaDefinition { /** Monotonically increasing plugin schema version for diagnostics. */ version: number; /** Stable snake_case namespace prefix for every referenced table (must end in `_`). */ tablePrefix: string; - /** One idempotent CREATE TABLE, CREATE INDEX, or ALTER TABLE statement per item. */ + /** One idempotent CREATE TABLE/INDEX or host-approved additive ALTER TABLE statement per item. */ statements: readonly string[]; } /** PostgreSQL-native schema hook. It receives no database handle. */ diff --git a/packages/core/src/postgres/plugin-schema-hook.ts b/packages/core/src/postgres/plugin-schema-hook.ts index d839404286..eb1f8cdea7 100644 --- a/packages/core/src/postgres/plugin-schema-hook.ts +++ b/packages/core/src/postgres/plugin-schema-hook.ts @@ -38,6 +38,41 @@ export type PluginSchemaInitHook = { init(db: PostgresJsDatabase>): Promise; }; +type ProjectIndexDefinition = { + readonly name: string; + readonly table: string; + readonly columns: string; + readonly unique?: boolean; +}; + +async function ensureProjectIndexes( + db: PostgresJsDatabase>, + definitions: readonly ProjectIndexDefinition[], +): Promise { + const rows = (await db.execute(sql` + SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'project' + `)) as unknown as Array<{ indexname: string; indexdef: string }>; + const actual = new Map(rows.map((row) => [row.indexname, row.indexdef])); + const stale = definitions.filter((definition) => { + const catalogName = /^[a-z_][a-z0-9_]*$/.test(definition.name) + ? definition.name + : `"${definition.name}"`; + const expected = `CREATE ${definition.unique ? "UNIQUE " : ""}INDEX ${catalogName} ON project.${definition.table} USING btree (${definition.columns})`; + return actual.get(definition.name) !== expected; + }); + if (stale.length === 0) return; + + /* + FNXC:PluginIndexIsolation 2026-07-14-23:55: + Every bundled-plugin lookup runs through a project-bound data layer. Reconcile named secondary indexes to project_id-leading definitions so PostgreSQL can prune other tenants before applying status, relationship, or time predicates; preserve matching index OIDs on steady-state boots. + */ + await db.execute(sql.raw(stale.map((definition) => ` + DROP INDEX IF EXISTS project."${definition.name}"; + CREATE ${definition.unique ? "UNIQUE " : ""}INDEX "${definition.name}" + ON project.${definition.table}(${definition.columns}); + `).join("\n"))); +} + /** * FNXC:PostgresSchema 2026-06-24-03:45: * Default roadmap plugin schema-init hook. Creates roadmaps, roadmap_milestones, @@ -49,44 +84,41 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { async init(db) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS project.roadmaps ( - id text PRIMARY KEY, - project_id text, + project_id text NOT NULL, + id text NOT NULL, title text NOT NULL, description text, created_at text NOT NULL, - updated_at text NOT NULL + updated_at text NOT NULL, + PRIMARY KEY (project_id, id) ); CREATE TABLE IF NOT EXISTS project.roadmap_milestones ( - id text PRIMARY KEY, - project_id text, + project_id text NOT NULL, + id text NOT NULL, roadmap_id text NOT NULL, title text NOT NULL, description text, order_index integer NOT NULL, created_at text NOT NULL, updated_at text NOT NULL, + PRIMARY KEY (project_id, id), CONSTRAINT roadmap_milestones_roadmap_id_fkey - FOREIGN KEY (roadmap_id) REFERENCES project.roadmaps(id) ON DELETE CASCADE + FOREIGN KEY (project_id, roadmap_id) REFERENCES project.roadmaps(project_id, id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS "idxRoadmapMilestonesRoadmapOrder" - ON project.roadmap_milestones(roadmap_id, order_index, created_at, id); - CREATE TABLE IF NOT EXISTS project.roadmap_features ( - id text PRIMARY KEY, - project_id text, + project_id text NOT NULL, + id text NOT NULL, milestone_id text NOT NULL, title text NOT NULL, description text, order_index integer NOT NULL, created_at text NOT NULL, updated_at text NOT NULL, + PRIMARY KEY (project_id, id), CONSTRAINT roadmap_features_milestone_id_fkey - FOREIGN KEY (milestone_id) REFERENCES project.roadmap_milestones(id) ON DELETE CASCADE + FOREIGN KEY (project_id, milestone_id) REFERENCES project.roadmap_milestones(project_id, id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS "idxRoadmapFeaturesMilestoneOrder" - ON project.roadmap_features(milestone_id, order_index, created_at, id); - /* * FNXC:PluginPostgresIsolation 2026-07-13-22:37: * Bundled plugin rows share one embedded PostgreSQL schema, so every roadmap hierarchy row must carry the bound project ID. The upgrade below derives or rejects legacy ownership before enforcing non-null, while runtime stores reject unbound layers and always filter these columns. @@ -94,23 +126,84 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { ALTER TABLE project.roadmaps ADD COLUMN IF NOT EXISTS project_id text; ALTER TABLE project.roadmap_milestones ADD COLUMN IF NOT EXISTS project_id text; ALTER TABLE project.roadmap_features ADD COLUMN IF NOT EXISTS project_id text; + `)); + + await ensureProjectIndexes(db, [ + { name: "idxRoadmapMilestonesRoadmapOrder", table: "roadmap_milestones", columns: "project_id, roadmap_id, order_index, created_at, id" }, + { name: "idxRoadmapFeaturesMilestoneOrder", table: "roadmap_features", columns: "project_id, milestone_id, order_index, created_at, id" }, + { name: "idxRoadmapsProject", table: "roadmaps", columns: "project_id, created_at, id" }, + { name: "idxRoadmapMilestonesProject", table: "roadmap_milestones", columns: "project_id, roadmap_id, order_index, id" }, + { name: "idxRoadmapFeaturesProject", table: "roadmap_features", columns: "project_id, milestone_id, order_index, id" }, + ]); + + const readiness = (await db.execute(sql` + SELECT + EXISTS ( + SELECT 1 FROM project.roadmaps WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.roadmap_milestones WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.roadmap_features WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'project.roadmaps'::regclass AND conname = 'roadmaps_pkey' + AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, id)' + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'project.roadmap_milestones'::regclass AND conname = 'roadmap_milestones_pkey' + AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, id)' + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'project.roadmap_features'::regclass AND conname = 'roadmap_features_pkey' + AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, id)' + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'project.roadmap_milestones'::regclass AND conname = 'roadmap_milestones_roadmap_id_fkey' + AND pg_get_constraintdef(oid) LIKE 'FOREIGN KEY (project_id, roadmap_id) REFERENCES project.roadmaps(project_id, id) ON DELETE CASCADE%' + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'project.roadmap_features'::regclass AND conname = 'roadmap_features_milestone_id_fkey' + AND pg_get_constraintdef(oid) LIKE 'FOREIGN KEY (project_id, milestone_id) REFERENCES project.roadmap_milestones(project_id, id) ON DELETE CASCADE%' + ) + AS needs_upgrade + `)) as unknown as Array<{ needs_upgrade: boolean }>; + /* + FNXC:PluginSchemaPerformance 2026-07-14-23:40: + PostgreSQL gate workers and production boots repeatedly apply bundled hooks. Preserve existing constraint OIDs when the Roadmap hierarchy already has project-local keys and no recoverable legacy ownership instead of taking unnecessary ACCESS EXCLUSIVE locks. + */ + if (readiness[0]?.needs_upgrade === false) return; + + await db.execute(sql.raw(` + + /* + * FNXC:RoadmapPostgresUpgrade 2026-07-14-22:45: + * Existing databases may already have composite hierarchy foreign keys from universal project isolation. Remove both relationships before repairing sentinel ownership so parent and child partitions can move together, then rebuild project-local keys and relationships only after the hierarchy is validated. + */ + ALTER TABLE project.roadmap_features DROP CONSTRAINT IF EXISTS roadmap_features_milestone_id_fkey; + ALTER TABLE project.roadmap_milestones DROP CONSTRAINT IF EXISTS roadmap_milestones_roadmap_id_fkey; /* * FNXC:RoadmapPostgresUpgrade 2026-07-13-23:40: * Project-bound Roadmap readers must never silently hide pre-partition PostgreSQL rows. Derive child ownership from an owned parent first, use the sole registered project only when that mapping is unambiguous, and abort schema startup when multiple/no projects leave ownership unknowable. Validate the complete hierarchy before making ownership mandatory. + * + * FNXC:PluginLegacyOwnership 2026-07-14-22:40: + * The core schema's non-null compatibility default marks pre-partition rows as __legacy_unscoped__. Treat that sentinel exactly like NULL/empty ownership in every bundled-plugin upgrade so a sole registered project can recover preserved data and ambiguous databases still fail closed. */ UPDATE project.roadmap_milestones milestone SET project_id = roadmap.project_id FROM project.roadmaps roadmap WHERE milestone.roadmap_id = roadmap.id - AND (milestone.project_id IS NULL OR milestone.project_id = '') + AND (milestone.project_id IS NULL OR milestone.project_id IN ('', '__legacy_unscoped__')) AND roadmap.project_id IS NOT NULL AND roadmap.project_id <> ''; UPDATE project.roadmap_features feature SET project_id = milestone.project_id FROM project.roadmap_milestones milestone WHERE feature.milestone_id = milestone.id - AND (feature.project_id IS NULL OR feature.project_id = '') + AND (feature.project_id IS NULL OR feature.project_id IN ('', '__legacy_unscoped__')) AND milestone.project_id IS NOT NULL AND milestone.project_id <> ''; @@ -122,9 +215,9 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { ownership_conflicts bigint; BEGIN SELECT - (SELECT count(*) FROM project.roadmaps WHERE project_id IS NULL OR project_id = '') - + (SELECT count(*) FROM project.roadmap_milestones WHERE project_id IS NULL OR project_id = '') - + (SELECT count(*) FROM project.roadmap_features WHERE project_id IS NULL OR project_id = '') + (SELECT count(*) FROM project.roadmaps WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.roadmap_milestones WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.roadmap_features WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) INTO unowned_count; IF unowned_count > 0 THEN @@ -135,20 +228,30 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { unowned_count, registered_project_count; END IF; UPDATE project.roadmaps SET project_id = singleton_project_id - WHERE project_id IS NULL OR project_id = ''; + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); UPDATE project.roadmap_milestones SET project_id = singleton_project_id - WHERE project_id IS NULL OR project_id = ''; + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); UPDATE project.roadmap_features SET project_id = singleton_project_id - WHERE project_id IS NULL OR project_id = ''; + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); END IF; + /* + * FNXC:RoadmapProjectIdentity 2026-07-14-23:55: + * Roadmap IDs are project-local after cutover. Validate each child against the composite parent identity instead of joining on id alone, which falsely rejects two valid projects that reuse the same roadmap or milestone ID. + */ SELECT (SELECT count(*) FROM project.roadmap_milestones milestone - JOIN project.roadmaps roadmap ON roadmap.id = milestone.roadmap_id - WHERE milestone.project_id IS DISTINCT FROM roadmap.project_id) + WHERE NOT EXISTS ( + SELECT 1 FROM project.roadmaps roadmap + WHERE roadmap.project_id = milestone.project_id + AND roadmap.id = milestone.roadmap_id + )) + (SELECT count(*) FROM project.roadmap_features feature - JOIN project.roadmap_milestones milestone ON milestone.id = feature.milestone_id - WHERE feature.project_id IS DISTINCT FROM milestone.project_id) + WHERE NOT EXISTS ( + SELECT 1 FROM project.roadmap_milestones milestone + WHERE milestone.project_id = feature.project_id + AND milestone.id = feature.milestone_id + )) INTO ownership_conflicts; IF ownership_conflicts > 0 THEN RAISE EXCEPTION 'Roadmap PostgreSQL upgrade found % cross-project hierarchy relationship(s)', ownership_conflicts; @@ -159,9 +262,16 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { ALTER TABLE project.roadmaps ALTER COLUMN project_id SET NOT NULL; ALTER TABLE project.roadmap_milestones ALTER COLUMN project_id SET NOT NULL; ALTER TABLE project.roadmap_features ALTER COLUMN project_id SET NOT NULL; - CREATE INDEX IF NOT EXISTS "idxRoadmapsProject" ON project.roadmaps(project_id, created_at, id); - CREATE INDEX IF NOT EXISTS "idxRoadmapMilestonesProject" ON project.roadmap_milestones(project_id, roadmap_id, order_index, id); - CREATE INDEX IF NOT EXISTS "idxRoadmapFeaturesProject" ON project.roadmap_features(project_id, milestone_id, order_index, id); + ALTER TABLE project.roadmap_features DROP CONSTRAINT IF EXISTS roadmap_features_pkey; + ALTER TABLE project.roadmap_milestones DROP CONSTRAINT IF EXISTS roadmap_milestones_pkey; + ALTER TABLE project.roadmaps DROP CONSTRAINT IF EXISTS roadmaps_pkey; + ALTER TABLE project.roadmaps ADD CONSTRAINT roadmaps_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.roadmap_milestones ADD CONSTRAINT roadmap_milestones_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.roadmap_features ADD CONSTRAINT roadmap_features_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.roadmap_milestones ADD CONSTRAINT roadmap_milestones_roadmap_id_fkey + FOREIGN KEY (project_id, roadmap_id) REFERENCES project.roadmaps(project_id, id) ON DELETE CASCADE; + ALTER TABLE project.roadmap_features ADD CONSTRAINT roadmap_features_milestone_id_fkey + FOREIGN KEY (project_id, milestone_id) REFERENCES project.roadmap_milestones(project_id, id) ON DELETE CASCADE; `)); }, }; @@ -205,13 +315,6 @@ export const cePluginSchemaInit: PluginSchemaInitHook = { -- integer via the CREATE TABLE IF NOT EXISTS above, so widen it in place. -- Idempotent: ALTER ... TYPE bigint on an already-bigint column is a no-op. ALTER TABLE project.ce_sessions ALTER COLUMN last_activity_at TYPE bigint; - CREATE INDEX IF NOT EXISTS "idxCeSessionsStatusUpdated" - ON project.ce_sessions(status, updated_at DESC, id); - CREATE INDEX IF NOT EXISTS "idxCeSessionsStageCreated" - ON project.ce_sessions(stage, created_at DESC, id); - CREATE INDEX IF NOT EXISTS "idxCeSessionsProject" - ON project.ce_sessions(project_id, updated_at DESC, id); - CREATE TABLE IF NOT EXISTS project.ce_plan_handoff_claims ( project_id text NOT NULL, artifact_path text NOT NULL, @@ -223,45 +326,126 @@ export const cePluginSchemaInit: PluginSchemaInitHook = { ); CREATE TABLE IF NOT EXISTS project.ce_pipeline_links ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, task_id text NOT NULL, ce_pipeline_id text NOT NULL, ce_stage_id text NOT NULL, ce_artifact_path text, - created_at text NOT NULL + created_at text NOT NULL, + PRIMARY KEY (project_id, id) ); - CREATE INDEX IF NOT EXISTS "idxCePipelineLinksPipeline" - ON project.ce_pipeline_links(ce_pipeline_id, created_at DESC, id); - CREATE UNIQUE INDEX IF NOT EXISTS "idxCePipelineLinksTask" - ON project.ce_pipeline_links(task_id); - CREATE TABLE IF NOT EXISTS project.ce_pipeline_state ( - ce_pipeline_id text PRIMARY KEY, + project_id text NOT NULL, + ce_pipeline_id text NOT NULL, current_stage text NOT NULL, status text NOT NULL CHECK (status IN ( 'running','advancing','awaiting_board','completed' )), last_artifact_path text, created_at text NOT NULL, - updated_at text NOT NULL + updated_at text NOT NULL, + PRIMARY KEY (project_id, ce_pipeline_id) ); - CREATE INDEX IF NOT EXISTS "idxCePipelineStateStatus" - ON project.ce_pipeline_state(status, updated_at DESC, ce_pipeline_id); CREATE TABLE IF NOT EXISTS project.ce_pipeline_sync_queue ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, ce_pipeline_id text NOT NULL, task_id text NOT NULL, reason text NOT NULL, from_column text, to_column text, enqueued_at text NOT NULL, - processed_at text + processed_at text, + PRIMARY KEY (project_id, id) ); - CREATE INDEX IF NOT EXISTS "idxCePipelineSyncQueuePending" - ON project.ce_pipeline_sync_queue(processed_at, enqueued_at, id); - CREATE INDEX IF NOT EXISTS "idxCePipelineSyncQueuePipeline" - ON project.ce_pipeline_sync_queue(ce_pipeline_id, enqueued_at, id); + + /* + * FNXC:CePipelineProjectIsolation 2026-07-14-21:41: + * Idempotently upgrade pre-partition plugin tables before runtime stores begin applying project predicates. Legacy rows may be assigned only when central.projects proves a single owner; a sentinel would make preserved pipeline state invisible to every project-scoped reader. + */ + ALTER TABLE project.ce_pipeline_links ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.ce_pipeline_state ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.ce_pipeline_sync_queue ADD COLUMN IF NOT EXISTS project_id text; + `)); + + await ensureProjectIndexes(db, [ + { name: "idxCeSessionsStatusUpdated", table: "ce_sessions", columns: "project_id, status, updated_at DESC, id" }, + { name: "idxCeSessionsStageCreated", table: "ce_sessions", columns: "project_id, stage, created_at DESC, id" }, + { name: "idxCeSessionsProject", table: "ce_sessions", columns: "project_id, updated_at DESC, id" }, + { name: "idxCePipelineLinksPipeline", table: "ce_pipeline_links", columns: "project_id, ce_pipeline_id, created_at DESC, id" }, + { name: "idxCePipelineLinksTask", table: "ce_pipeline_links", columns: "project_id, task_id", unique: true }, + { name: "idxCePipelineStateStatus", table: "ce_pipeline_state", columns: "project_id, status, updated_at DESC, ce_pipeline_id" }, + { name: "idxCePipelineSyncQueuePending", table: "ce_pipeline_sync_queue", columns: "project_id, processed_at, enqueued_at, id" }, + { name: "idxCePipelineSyncQueuePipeline", table: "ce_pipeline_sync_queue", columns: "project_id, ce_pipeline_id, enqueued_at, id" }, + ]); + + const readiness = (await db.execute(sql` + SELECT + EXISTS ( + SELECT 1 FROM project.ce_pipeline_links WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.ce_pipeline_state WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.ce_pipeline_sync_queue WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conrelid = 'project.ce_pipeline_links'::regclass + AND conname = 'ce_pipeline_links_pkey' AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, id)' + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conrelid = 'project.ce_pipeline_state'::regclass + AND conname = 'ce_pipeline_state_pkey' AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, ce_pipeline_id)' + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conrelid = 'project.ce_pipeline_sync_queue'::regclass + AND conname = 'ce_pipeline_sync_queue_pkey' AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, id)' + ) + AS needs_upgrade + `)) as unknown as Array<{ needs_upgrade: boolean }>; + /* + FNXC:PluginSchemaPerformance 2026-07-14-23:40: + Keep steady-state Compound Engineering startup validation read-only once pipeline identities and task uniqueness already use project-local keys; legacy rows or stale catalog definitions still enter the fail-closed upgrade. + */ + if (readiness[0]?.needs_upgrade === false) return; + + await db.execute(sql.raw(` + DO $ce_pipeline_upgrade$ + DECLARE + unowned_count bigint; + registered_project_count bigint; + singleton_project_id text; + BEGIN + SELECT + (SELECT count(*) FROM project.ce_pipeline_links WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.ce_pipeline_state WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.ce_pipeline_sync_queue WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + INTO unowned_count; + + IF unowned_count > 0 THEN + SELECT count(*), min(id) INTO registered_project_count, singleton_project_id + FROM central.projects; + IF registered_project_count <> 1 THEN + RAISE EXCEPTION 'Compound Engineering PostgreSQL upgrade cannot assign % pre-project pipeline row(s) across % registered projects', + unowned_count, registered_project_count; + END IF; + UPDATE project.ce_pipeline_links SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + UPDATE project.ce_pipeline_state SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + UPDATE project.ce_pipeline_sync_queue SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + END IF; + END + $ce_pipeline_upgrade$; + ALTER TABLE project.ce_pipeline_links ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.ce_pipeline_state ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.ce_pipeline_sync_queue ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.ce_pipeline_links DROP CONSTRAINT IF EXISTS ce_pipeline_links_pkey; + ALTER TABLE project.ce_pipeline_links ADD CONSTRAINT ce_pipeline_links_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.ce_pipeline_state DROP CONSTRAINT IF EXISTS ce_pipeline_state_pkey; + ALTER TABLE project.ce_pipeline_state ADD CONSTRAINT ce_pipeline_state_pkey PRIMARY KEY (project_id, ce_pipeline_id); + ALTER TABLE project.ce_pipeline_sync_queue DROP CONSTRAINT IF EXISTS ce_pipeline_sync_queue_pkey; + ALTER TABLE project.ce_pipeline_sync_queue ADD CONSTRAINT ce_pipeline_sync_queue_pkey PRIMARY KEY (project_id, id); `)); }, }; @@ -362,7 +546,8 @@ export const reportsPluginSchemaInit: PluginSchemaInitHook = { async init(db) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS project.reports ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, cadence text NOT NULL CHECK (cadence IN ('daily','weekly','monthly','quarterly','manual')), period_start text NOT NULL, period_end text NOT NULL, @@ -386,17 +571,66 @@ export const reportsPluginSchemaInit: PluginSchemaInitHook = { metadata_json text NOT NULL DEFAULT '{}', combined_review_json text, created_at text NOT NULL, - updated_at text NOT NULL + updated_at text NOT NULL, + PRIMARY KEY (project_id, id) ); - CREATE INDEX IF NOT EXISTS "idxReportsCadenceCreated" - ON project.reports(cadence, created_at DESC, id); + /* + * FNXC:ReportsProjectIsolation 2026-07-14-21:41: + * Upgrade existing report rows without hiding preserved reports behind an unqueryable sentinel. Only a single central.projects registration establishes unambiguous ownership; otherwise schema startup fails before the composite identity is enforced. + */ + ALTER TABLE project.reports ADD COLUMN IF NOT EXISTS project_id text; + `)); - CREATE INDEX IF NOT EXISTS "idxReportsStatusUpdated" - ON project.reports(status, updated_at DESC, id); + await ensureProjectIndexes(db, [ + { name: "idxReportsCadenceCreated", table: "reports", columns: "project_id, cadence, created_at DESC, id" }, + { name: "idxReportsStatusUpdated", table: "reports", columns: "project_id, status, updated_at DESC, id" }, + { name: "idxReportsPeriod", table: "reports", columns: "project_id, period_start, period_end, id" }, + ]); - CREATE INDEX IF NOT EXISTS "idxReportsPeriod" - ON project.reports(period_start, period_end, id); + const readiness = (await db.execute(sql` + SELECT + EXISTS ( + SELECT 1 FROM project.reports + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + ) + OR NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conrelid = 'project.reports'::regclass + AND conname = 'reports_pkey' AND pg_get_constraintdef(oid) = 'PRIMARY KEY (project_id, id)' + ) AS needs_upgrade + `)) as unknown as Array<{ needs_upgrade: boolean }>; + /* + FNXC:PluginSchemaPerformance 2026-07-14-23:40: + Reports schema validation must not replace an already-correct composite primary key on every boot. Only legacy ownership or a stale key shape requires the destructive upgrade path. + */ + if (readiness[0]?.needs_upgrade === false) return; + + await db.execute(sql.raw(` + DO $reports_upgrade$ + DECLARE + unowned_count bigint; + registered_project_count bigint; + singleton_project_id text; + BEGIN + SELECT count(*) INTO unowned_count + FROM project.reports + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + + IF unowned_count > 0 THEN + SELECT count(*), min(id) INTO registered_project_count, singleton_project_id + FROM central.projects; + IF registered_project_count <> 1 THEN + RAISE EXCEPTION 'Reports PostgreSQL upgrade cannot assign % pre-project report row(s) across % registered projects', + unowned_count, registered_project_count; + END IF; + UPDATE project.reports SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + END IF; + END + $reports_upgrade$; + ALTER TABLE project.reports ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.reports DROP CONSTRAINT IF EXISTS reports_pkey; + ALTER TABLE project.reports ADD CONSTRAINT reports_pkey PRIMARY KEY (project_id, id); `)); }, }; @@ -416,19 +650,23 @@ export const cliPressPluginSchemaInit: PluginSchemaInitHook = { async init(db) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS project.cli_press_services ( - id text PRIMARY KEY, - slug text NOT NULL UNIQUE, + project_id text NOT NULL, + id text NOT NULL, + slug text NOT NULL, display_name text NOT NULL, description text, base_url text NOT NULL, source_kind text NOT NULL, source_ref text, created_at text NOT NULL, - updated_at text NOT NULL + updated_at text NOT NULL, + PRIMARY KEY (project_id, id), + CONSTRAINT uq_cli_press_services_project_slug UNIQUE (project_id, slug) ); CREATE TABLE IF NOT EXISTS project.cli_press_cli_specs ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, service_id text NOT NULL, name text NOT NULL, version text NOT NULL, @@ -439,15 +677,14 @@ export const cliPressPluginSchemaInit: PluginSchemaInitHook = { last_generation_error text, created_at text NOT NULL, updated_at text NOT NULL, + PRIMARY KEY (project_id, id), CONSTRAINT cli_press_cli_specs_service_id_fkey - FOREIGN KEY (service_id) REFERENCES project.cli_press_services(id) ON DELETE CASCADE, - CONSTRAINT uq_cli_press_specs_service_name UNIQUE (service_id, name) + FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE, + CONSTRAINT uq_cli_press_specs_service_name UNIQUE (project_id, service_id, name) ); - CREATE INDEX IF NOT EXISTS "idx_cli_press_specs_service" - ON project.cli_press_cli_specs(service_id, created_at, id); - CREATE TABLE IF NOT EXISTS project.cli_press_artifacts ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, cli_spec_id text NOT NULL, kind text NOT NULL, path text NOT NULL, @@ -456,14 +693,13 @@ export const cliPressPluginSchemaInit: PluginSchemaInitHook = { size_bytes integer, created_at text NOT NULL, updated_at text NOT NULL, + PRIMARY KEY (project_id, id), CONSTRAINT cli_press_artifacts_cli_spec_id_fkey - FOREIGN KEY (cli_spec_id) REFERENCES project.cli_press_cli_specs(id) ON DELETE CASCADE + FOREIGN KEY (project_id, cli_spec_id) REFERENCES project.cli_press_cli_specs(project_id, id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS "idx_cli_press_artifacts_spec" - ON project.cli_press_artifacts(cli_spec_id, created_at, id); - CREATE TABLE IF NOT EXISTS project.cli_press_credentials ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, service_id text NOT NULL, name text NOT NULL, kind text NOT NULL, @@ -471,27 +707,154 @@ export const cliPressPluginSchemaInit: PluginSchemaInitHook = { placement text NOT NULL, created_at text NOT NULL, updated_at text NOT NULL, + PRIMARY KEY (project_id, id), CONSTRAINT cli_press_credentials_service_id_fkey - FOREIGN KEY (service_id) REFERENCES project.cli_press_services(id) ON DELETE CASCADE, - CONSTRAINT uq_cli_press_credentials_service_name UNIQUE (service_id, name) + FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE, + CONSTRAINT uq_cli_press_credentials_service_name UNIQUE (project_id, service_id, name) ); - CREATE INDEX IF NOT EXISTS "idx_cli_press_credentials_service" - ON project.cli_press_credentials(service_id, created_at, id); - CREATE TABLE IF NOT EXISTS project.cli_press_service_settings ( - id text PRIMARY KEY, + project_id text NOT NULL, + id text NOT NULL, service_id text NOT NULL, key text NOT NULL, value text NOT NULL, scope text NOT NULL, created_at text NOT NULL, updated_at text NOT NULL, + PRIMARY KEY (project_id, id), CONSTRAINT cli_press_service_settings_service_id_fkey - FOREIGN KEY (service_id) REFERENCES project.cli_press_services(id) ON DELETE CASCADE, - CONSTRAINT uq_cli_press_settings_service_key_scope UNIQUE (service_id, key, scope) + FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE, + CONSTRAINT uq_cli_press_settings_service_key_scope UNIQUE (project_id, service_id, key, scope) ); - CREATE INDEX IF NOT EXISTS "idx_cli_press_settings_service" - ON project.cli_press_service_settings(service_id, created_at, id); + /* + * FNXC:CliPressProjectIsolation 2026-07-14-21:41: + * Upgrade every legacy table together so composite ownership keys and child foreign keys remain valid across repeated schema application. Pre-project definitions may be claimed only by the sole registered project; ambiguous ownership fails closed instead of assigning rows to an invisible sentinel. + */ + ALTER TABLE project.cli_press_services ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.cli_press_cli_specs ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.cli_press_artifacts ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.cli_press_credentials ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.cli_press_service_settings ADD COLUMN IF NOT EXISTS project_id text; + `)); + + await ensureProjectIndexes(db, [ + { name: "idx_cli_press_specs_service", table: "cli_press_cli_specs", columns: "project_id, service_id, created_at, id" }, + { name: "idx_cli_press_artifacts_spec", table: "cli_press_artifacts", columns: "project_id, cli_spec_id, created_at, id" }, + { name: "idx_cli_press_credentials_service", table: "cli_press_credentials", columns: "project_id, service_id, created_at, id" }, + { name: "idx_cli_press_settings_service", table: "cli_press_service_settings", columns: "project_id, service_id, created_at, id" }, + ]); + + const readiness = (await db.execute(sql` + SELECT + EXISTS ( + SELECT 1 FROM project.cli_press_services WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.cli_press_cli_specs WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.cli_press_artifacts WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.cli_press_credentials WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + UNION ALL SELECT 1 FROM project.cli_press_service_settings WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__') + ) + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('project.cli_press_services', 'cli_press_services_pkey', 'PRIMARY KEY (project_id, id)'), + ('project.cli_press_services', 'uq_cli_press_services_project_slug', 'UNIQUE (project_id, slug)'), + ('project.cli_press_cli_specs', 'cli_press_cli_specs_pkey', 'PRIMARY KEY (project_id, id)'), + ('project.cli_press_cli_specs', 'uq_cli_press_specs_service_name', 'UNIQUE (project_id, service_id, name)'), + ('project.cli_press_cli_specs', 'cli_press_cli_specs_service_id_fkey', 'FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE'), + ('project.cli_press_artifacts', 'cli_press_artifacts_pkey', 'PRIMARY KEY (project_id, id)'), + ('project.cli_press_artifacts', 'cli_press_artifacts_cli_spec_id_fkey', 'FOREIGN KEY (project_id, cli_spec_id) REFERENCES project.cli_press_cli_specs(project_id, id) ON DELETE CASCADE'), + ('project.cli_press_credentials', 'cli_press_credentials_pkey', 'PRIMARY KEY (project_id, id)'), + ('project.cli_press_credentials', 'uq_cli_press_credentials_service_name', 'UNIQUE (project_id, service_id, name)'), + ('project.cli_press_credentials', 'cli_press_credentials_service_id_fkey', 'FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE'), + ('project.cli_press_service_settings', 'cli_press_service_settings_pkey', 'PRIMARY KEY (project_id, id)'), + ('project.cli_press_service_settings', 'uq_cli_press_settings_service_key_scope', 'UNIQUE (project_id, service_id, key, scope)'), + ('project.cli_press_service_settings', 'cli_press_service_settings_service_id_fkey', 'FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE') + ) AS expected(table_name, constraint_name, definition) + WHERE NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = expected.table_name::regclass + AND conname = expected.constraint_name + AND pg_get_constraintdef(oid) LIKE expected.definition || '%' + ) + ) AS needs_upgrade + `)) as unknown as Array<{ needs_upgrade: boolean }>; + /* + FNXC:PluginSchemaPerformance 2026-07-14-23:40: + The CLI Printing Press hierarchy has thirteen project-local identity constraints. Treat a matching catalog plus fully owned rows as steady state so repeated schema application never drops and recreates the hierarchy. + */ + if (readiness[0]?.needs_upgrade === false) return; + + await db.execute(sql.raw(` + /* + * FNXC:CliPressProjectIsolation 2026-07-14-22:48: + * A repeated upgrade can encounter composite child foreign keys installed by an earlier boot. Drop them before moving sentinel-owned parents and children to the recovered project together; the same transaction rebuilds every relationship after ownership validation. + */ + ALTER TABLE project.cli_press_artifacts DROP CONSTRAINT IF EXISTS cli_press_artifacts_cli_spec_id_fkey; + ALTER TABLE project.cli_press_cli_specs DROP CONSTRAINT IF EXISTS cli_press_cli_specs_service_id_fkey; + ALTER TABLE project.cli_press_credentials DROP CONSTRAINT IF EXISTS cli_press_credentials_service_id_fkey; + ALTER TABLE project.cli_press_service_settings DROP CONSTRAINT IF EXISTS cli_press_service_settings_service_id_fkey; + DO $cli_press_upgrade$ + DECLARE + unowned_count bigint; + registered_project_count bigint; + singleton_project_id text; + BEGIN + SELECT + (SELECT count(*) FROM project.cli_press_services WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.cli_press_cli_specs WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.cli_press_artifacts WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.cli_press_credentials WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + + (SELECT count(*) FROM project.cli_press_service_settings WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__')) + INTO unowned_count; + + IF unowned_count > 0 THEN + SELECT count(*), min(id) INTO registered_project_count, singleton_project_id + FROM central.projects; + IF registered_project_count <> 1 THEN + RAISE EXCEPTION 'CLI Printing Press PostgreSQL upgrade cannot assign % pre-project row(s) across % registered projects', + unowned_count, registered_project_count; + END IF; + UPDATE project.cli_press_services SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + UPDATE project.cli_press_cli_specs SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + UPDATE project.cli_press_artifacts SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + UPDATE project.cli_press_credentials SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + UPDATE project.cli_press_service_settings SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id IN ('', '__legacy_unscoped__'); + END IF; + END + $cli_press_upgrade$; + ALTER TABLE project.cli_press_services ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.cli_press_cli_specs ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.cli_press_artifacts ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.cli_press_credentials ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.cli_press_service_settings ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.cli_press_artifacts DROP CONSTRAINT IF EXISTS cli_press_artifacts_pkey; + ALTER TABLE project.cli_press_cli_specs DROP CONSTRAINT IF EXISTS cli_press_cli_specs_pkey; + ALTER TABLE project.cli_press_credentials DROP CONSTRAINT IF EXISTS cli_press_credentials_pkey; + ALTER TABLE project.cli_press_service_settings DROP CONSTRAINT IF EXISTS cli_press_service_settings_pkey; + ALTER TABLE project.cli_press_services DROP CONSTRAINT IF EXISTS cli_press_services_pkey; + ALTER TABLE project.cli_press_services DROP CONSTRAINT IF EXISTS cli_press_services_slug_key; + ALTER TABLE project.cli_press_services DROP CONSTRAINT IF EXISTS uq_cli_press_services_project_slug; + ALTER TABLE project.cli_press_cli_specs DROP CONSTRAINT IF EXISTS uq_cli_press_specs_service_name; + ALTER TABLE project.cli_press_credentials DROP CONSTRAINT IF EXISTS uq_cli_press_credentials_service_name; + ALTER TABLE project.cli_press_service_settings DROP CONSTRAINT IF EXISTS uq_cli_press_settings_service_key_scope; + ALTER TABLE project.cli_press_services ADD CONSTRAINT cli_press_services_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.cli_press_services ADD CONSTRAINT uq_cli_press_services_project_slug UNIQUE (project_id, slug); + ALTER TABLE project.cli_press_cli_specs ADD CONSTRAINT cli_press_cli_specs_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.cli_press_cli_specs ADD CONSTRAINT uq_cli_press_specs_service_name UNIQUE (project_id, service_id, name); + ALTER TABLE project.cli_press_artifacts ADD CONSTRAINT cli_press_artifacts_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.cli_press_credentials ADD CONSTRAINT cli_press_credentials_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.cli_press_credentials ADD CONSTRAINT uq_cli_press_credentials_service_name UNIQUE (project_id, service_id, name); + ALTER TABLE project.cli_press_service_settings ADD CONSTRAINT cli_press_service_settings_pkey PRIMARY KEY (project_id, id); + ALTER TABLE project.cli_press_service_settings ADD CONSTRAINT uq_cli_press_settings_service_key_scope UNIQUE (project_id, service_id, key, scope); + ALTER TABLE project.cli_press_cli_specs ADD CONSTRAINT cli_press_cli_specs_service_id_fkey FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE; + ALTER TABLE project.cli_press_artifacts ADD CONSTRAINT cli_press_artifacts_cli_spec_id_fkey FOREIGN KEY (project_id, cli_spec_id) REFERENCES project.cli_press_cli_specs(project_id, id) ON DELETE CASCADE; + ALTER TABLE project.cli_press_credentials ADD CONSTRAINT cli_press_credentials_service_id_fkey FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE; + ALTER TABLE project.cli_press_service_settings ADD CONSTRAINT cli_press_service_settings_service_id_fkey FOREIGN KEY (project_id, service_id) REFERENCES project.cli_press_services(project_id, id) ON DELETE CASCADE; `)); }, }; @@ -513,8 +876,22 @@ const POSTGRES_PLUGIN_SCHEMA_HOOKS = new Map( DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS.map((hook) => [hook.pluginId, hook] as const), ); -const SAFE_POSTGRES_PLUGIN_STATEMENT = /^(?:CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.[a-z][a-z0-9_]*\s*\(|CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+(?:"[^"]+"|[a-z][a-z0-9_]*)\s+ON\s+project\.[a-z][a-z0-9_]*\s*\(|ALTER\s+TABLE\s+project\.[a-z][a-z0-9_]*\s+)/i; +const SAFE_POSTGRES_PLUGIN_CREATE_STATEMENT = /^(?:CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.[a-z][a-z0-9_]*\s*\(|CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+(?:"[^"]+"|[a-z][a-z0-9_]*)\s+ON\s+project\.[a-z][a-z0-9_]*\s*\()/i; const CREATE_PLUGIN_TABLE = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.([a-z][a-z0-9_]*)\s*\(/i; +const CREATE_PLUGIN_INDEX = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+(?:"[^"]+"|[a-z][a-z0-9_]*)\s+ON\s+project\.([a-z][a-z0-9_]*)\s*\(/i; +const ALTER_PLUGIN_TABLE = /^ALTER\s+TABLE\s+project\.([a-z][a-z0-9_]*)\s+(.+)$/is; +const SAFE_PLUGIN_COLUMN_TYPE = "(?:text|integer|bigint|boolean|jsonb|timestamp(?:\\s+(?:with|without)\\s+time\\s+zone)?)"; +const SAFE_PLUGIN_DEFAULT = "(?:NULL|TRUE|FALSE|-?[0-9]+(?:\\.[0-9]+)?|'(?:''|[^'])*'|[a-z][a-z0-9_]*(?:\\(\\))?)"; +const SAFE_PLUGIN_ALTER_ACTION = new RegExp( + `^(?:ADD\\s+COLUMN\\s+IF\\s+NOT\\s+EXISTS\\s+("?[a-z][a-z0-9_]*"?)\\s+${SAFE_PLUGIN_COLUMN_TYPE}(?:\\s+NOT\\s+NULL)?(?:\\s+DEFAULT\\s+${SAFE_PLUGIN_DEFAULT})?|ALTER\\s+COLUMN\\s+("?[a-z][a-z0-9_]*"?)\\s+SET\\s+(?:NOT\\s+NULL|DEFAULT\\s+${SAFE_PLUGIN_DEFAULT}))$`, + "i", +); + +function pluginStatementTable(normalized: string): string | undefined { + return normalized.match(CREATE_PLUGIN_TABLE)?.[1] + ?? normalized.match(CREATE_PLUGIN_INDEX)?.[1] + ?? normalized.match(ALTER_PLUGIN_TABLE)?.[1]; +} /** * Validate a third-party schema plan before plugin lifecycle side effects run. @@ -536,10 +913,24 @@ export function validatePluginPostgresSchema( } for (const statement of definition.statements) { const normalized = statement.trim().replace(/;\s*$/, ""); - if (!normalized || normalized.includes(";")) { + if (!normalized || normalized.includes(";") || /--|\/\*/.test(normalized)) { throw new Error(`Plugin "${pluginId}" PostgreSQL schema requires exactly one statement per item`); } - if (!SAFE_POSTGRES_PLUGIN_STATEMENT.test(normalized)) { + const alter = normalized.match(ALTER_PLUGIN_TABLE); + if (alter) { + const action = alter[2].trim(); + const safeAction = action.match(SAFE_PLUGIN_ALTER_ACTION); + const column = (safeAction?.[1] ?? safeAction?.[2])?.replaceAll('"', "").toLowerCase(); + /* + FNXC:PluginPostgresContract 2026-07-14-22:42: + Third-party migrations may evolve their own ordinary columns, but the privileged schema executor must retain sole ownership of project_id, keys, policies, triggers, grants, and table identity. A narrow additive ALTER grammar prevents a declarative hook from using migration credentials to weaken the host-installed isolation envelope. + */ + if (!safeAction || column === "project_id") { + throw new Error( + `Plugin "${pluginId}" PostgreSQL ALTER TABLE may only add or set defaults/nullability on non-project_id data columns`, + ); + } + } else if (!SAFE_POSTGRES_PLUGIN_CREATE_STATEMENT.test(normalized)) { throw new Error( `Plugin "${pluginId}" PostgreSQL schema may only use idempotent CREATE TABLE/INDEX or ALTER TABLE statements in the project schema`, ); @@ -593,22 +984,28 @@ export async function runLoadedPluginSchemaInitHooks( ): Promise { assertLoadedPluginSchemaInitHooksSupported(hooks); for (const loaded of hooks) { - if (loaded.postgresSchema) { - const tables = new Set(); - for (const statement of loaded.postgresSchema.statements) { - const normalized = statement.trim().replace(/;\s*$/, ""); - const table = normalized.match(CREATE_PLUGIN_TABLE)?.[1]; - if (table) tables.add(table); - await db.execute(sql.raw(normalized)); - } - for (const table of tables) { - /* - FNXC:PluginPostgresContract 2026-07-14-18:32: - Fusion owns the isolation envelope for third-party tables. Plugins - declare project-local keys; the privileged executor installs forced - RLS, ownership stamping, runtime grants, and a single scoped policy. - */ - await db.execute(sql.raw(` + /* + FNXC:PluginPostgresContract 2026-07-14-22:42: + Runtime load and hot reload share the schema-applier advisory lock. Each contract and its complete isolation envelope commit atomically, so concurrent Fusion processes serialize DDL and a rejected reload cannot leave partially-created or temporarily unprotected tables behind. + */ + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:schema-applier'))`); + if (loaded.postgresSchema) { + const tables = new Set(); + for (const statement of loaded.postgresSchema.statements) { + const normalized = statement.trim().replace(/;\s*$/, ""); + const table = pluginStatementTable(normalized); + if (table) tables.add(table); + await tx.execute(sql.raw(normalized)); + } + for (const table of tables) { + /* + FNXC:PluginPostgresContract 2026-07-14-18:32: + Fusion owns the isolation envelope for third-party tables. Plugins + declare project-local keys; the privileged executor installs forced + RLS, ownership stamping, runtime grants, and a single scoped policy. + */ + await tx.execute(sql.raw(` ALTER TABLE project."${table}" ALTER COLUMN project_id SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'); ALTER TABLE project."${table}" ENABLE ROW LEVEL SECURITY; @@ -622,11 +1019,12 @@ export async function runLoadedPluginSchemaInitHooks( ON project."${table}" FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); GRANT SELECT, INSERT, UPDATE, DELETE ON project."${table}" TO fusion_runtime; `)); + } + return; } - continue; - } - const postgresHook = POSTGRES_PLUGIN_SCHEMA_HOOKS.get(loaded.pluginId); - if (postgresHook) await postgresHook.init(db); + const postgresHook = POSTGRES_PLUGIN_SCHEMA_HOOKS.get(loaded.pluginId); + if (postgresHook) await postgresHook.init(tx); + }); } } diff --git a/packages/core/src/postgres/schema/plugin.ts b/packages/core/src/postgres/schema/plugin.ts index 4a1eacfddc..0b51570429 100644 --- a/packages/core/src/postgres/schema/plugin.ts +++ b/packages/core/src/postgres/schema/plugin.ts @@ -24,17 +24,19 @@ import { projectSchema } from "./project.js"; * plugin instantiates core's Database against the project connection. */ export const roadmaps = projectSchema.table("roadmaps", { - id: text("id").primaryKey(), + id: text("id").notNull(), /** FNXC:RoadmapPostgresUpgrade 2026-07-13-23:40: Runtime Roadmap rows always carry the project partition enforced by the plugin upgrade hook. */ projectId: text("project_id").notNull(), title: text("title").notNull(), description: text("description"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), -}); +}, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), +]); export const roadmapMilestones = projectSchema.table("roadmap_milestones", { - id: text("id").primaryKey(), + id: text("id").notNull(), projectId: text("project_id").notNull(), roadmapId: text("roadmap_id").notNull(), title: text("title").notNull(), @@ -43,12 +45,13 @@ export const roadmapMilestones = projectSchema.table("roadmap_milestones", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.roadmapId], foreignColumns: [roadmaps.id] }).onDelete("cascade"), - index("idxRoadmapMilestonesRoadmapOrder").on(t.roadmapId, t.orderIndex, t.createdAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.roadmapId], foreignColumns: [roadmaps.projectId, roadmaps.id] }).onDelete("cascade"), + index("idxRoadmapMilestonesRoadmapOrder").on(t.projectId, t.roadmapId, t.orderIndex, t.createdAt, t.id), ]); export const roadmapFeatures = projectSchema.table("roadmap_features", { - id: text("id").primaryKey(), + id: text("id").notNull(), projectId: text("project_id").notNull(), milestoneId: text("milestone_id").notNull(), title: text("title").notNull(), @@ -57,8 +60,9 @@ export const roadmapFeatures = projectSchema.table("roadmap_features", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.milestoneId], foreignColumns: [roadmapMilestones.id] }).onDelete("cascade"), - index("idxRoadmapFeaturesMilestoneOrder").on(t.milestoneId, t.orderIndex, t.createdAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.milestoneId], foreignColumns: [roadmapMilestones.projectId, roadmapMilestones.id] }).onDelete("cascade"), + index("idxRoadmapFeaturesMilestoneOrder").on(t.projectId, t.milestoneId, t.orderIndex, t.createdAt, t.id), ]); /** @@ -126,32 +130,37 @@ export const ceSessions = projectSchema.table("ce_sessions", { /** ce_pipeline_links (U7) — board-task ↔ CE-pipeline/stage/artifact back-ref. */ export const cePipelineLinks = projectSchema.table("ce_pipeline_links", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), taskId: text("task_id").notNull(), cePipelineId: text("ce_pipeline_id").notNull(), ceStageId: text("ce_stage_id").notNull(), ceArtifactPath: text("ce_artifact_path"), createdAt: text("created_at").notNull(), }, (t) => [ - index("idxCePipelineLinksPipeline").on(t.cePipelineId, t.createdAt, t.id), - uniqueIndex("idxCePipelineLinksTask").on(t.taskId), + primaryKey({ columns: [t.projectId, t.id] }), + index("idxCePipelineLinksPipeline").on(t.projectId, t.cePipelineId, t.createdAt, t.id), + uniqueIndex("idxCePipelineLinksTask").on(t.projectId, t.taskId), ]); /** ce_pipeline_state (U8) — CE pipeline's OWN state machine (vs board columns). */ export const cePipelineState = projectSchema.table("ce_pipeline_state", { - cePipelineId: text("ce_pipeline_id").primaryKey(), + projectId: text("project_id").notNull(), + cePipelineId: text("ce_pipeline_id").notNull(), currentStage: text("current_stage").notNull(), status: text("status").notNull(), lastArtifactPath: text("last_artifact_path"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - index("idxCePipelineStateStatus").on(t.status, t.updatedAt, t.cePipelineId), + primaryKey({ columns: [t.projectId, t.cePipelineId] }), + index("idxCePipelineStateStatus").on(t.projectId, t.status, t.updatedAt, t.cePipelineId), ]); /** ce_pipeline_sync_queue (U8 / FN-5719) — board→pipeline sync signal queue. */ export const cePipelineSyncQueue = projectSchema.table("ce_pipeline_sync_queue", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), cePipelineId: text("ce_pipeline_id").notNull(), taskId: text("task_id").notNull(), reason: text("reason").notNull(), @@ -160,8 +169,9 @@ export const cePipelineSyncQueue = projectSchema.table("ce_pipeline_sync_queue", enqueuedAt: text("enqueued_at").notNull(), processedAt: text("processed_at"), }, (t) => [ - index("idxCePipelineSyncQueuePending").on(t.processedAt, t.enqueuedAt, t.id), - index("idxCePipelineSyncQueuePipeline").on(t.cePipelineId, t.enqueuedAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + index("idxCePipelineSyncQueuePending").on(t.projectId, t.processedAt, t.enqueuedAt, t.id), + index("idxCePipelineSyncQueuePipeline").on(t.projectId, t.cePipelineId, t.enqueuedAt, t.id), ]); /** @@ -190,7 +200,8 @@ export const cePluginTableNames = [ /** reports — generated activity reports with multi-agent review + approval. */ export const reports = projectSchema.table("reports", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), cadence: text("cadence").notNull(), periodStart: text("period_start").notNull(), periodEnd: text("period_end").notNull(), @@ -216,9 +227,10 @@ export const reports = projectSchema.table("reports", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - index("idxReportsCadenceCreated").on(t.cadence, t.createdAt, t.id), - index("idxReportsStatusUpdated").on(t.status, t.updatedAt, t.id), - index("idxReportsPeriod").on(t.periodStart, t.periodEnd, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + index("idxReportsCadenceCreated").on(t.projectId, t.cadence, t.createdAt, t.id), + index("idxReportsStatusUpdated").on(t.projectId, t.status, t.updatedAt, t.id), + index("idxReportsPeriod").on(t.projectId, t.periodStart, t.periodEnd, t.id), ]); /** @@ -244,8 +256,9 @@ export const reportsPluginTableNames = [ /** cli_press_services — registered external-service CLI definitions. */ export const cliPressServices = projectSchema.table("cli_press_services", { - id: text("id").primaryKey(), - slug: text("slug").notNull().unique(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), + slug: text("slug").notNull(), displayName: text("display_name").notNull(), description: text("description"), baseUrl: text("base_url").notNull(), @@ -253,11 +266,15 @@ export const cliPressServices = projectSchema.table("cli_press_services", { sourceRef: text("source_ref"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), -}); +}, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), + uniqueIndex("uq_cli_press_services_project_slug").on(t.projectId, t.slug), +]); /** cli_press_cli_specs — generated CLI specs scoped to a service. */ export const cliPressSpecs = projectSchema.table("cli_press_cli_specs", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), serviceId: text("service_id").notNull(), name: text("name").notNull(), version: text("version").notNull(), @@ -269,14 +286,16 @@ export const cliPressSpecs = projectSchema.table("cli_press_cli_specs", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.serviceId], foreignColumns: [cliPressServices.id] }).onDelete("cascade"), - uniqueIndex("uq_cli_press_specs_service_name").on(t.serviceId, t.name), - index("idx_cli_press_specs_service").on(t.serviceId, t.createdAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.serviceId], foreignColumns: [cliPressServices.projectId, cliPressServices.id] }).onDelete("cascade"), + uniqueIndex("uq_cli_press_specs_service_name").on(t.projectId, t.serviceId, t.name), + index("idx_cli_press_specs_service").on(t.projectId, t.serviceId, t.createdAt, t.id), ]); /** cli_press_artifacts — built CLI artifacts (binaries/scripts/packages). */ export const cliPressArtifacts = projectSchema.table("cli_press_artifacts", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), cliSpecId: text("cli_spec_id").notNull(), kind: text("kind").notNull(), path: text("path").notNull(), @@ -286,13 +305,15 @@ export const cliPressArtifacts = projectSchema.table("cli_press_artifacts", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.cliSpecId], foreignColumns: [cliPressSpecs.id] }).onDelete("cascade"), - index("idx_cli_press_artifacts_spec").on(t.cliSpecId, t.createdAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.cliSpecId], foreignColumns: [cliPressSpecs.projectId, cliPressSpecs.id] }).onDelete("cascade"), + index("idx_cli_press_artifacts_spec").on(t.projectId, t.cliSpecId, t.createdAt, t.id), ]); /** cli_press_credentials — auth credentials scoped to a service. */ export const cliPressCredentials = projectSchema.table("cli_press_credentials", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), serviceId: text("service_id").notNull(), name: text("name").notNull(), kind: text("kind").notNull(), @@ -301,14 +322,16 @@ export const cliPressCredentials = projectSchema.table("cli_press_credentials", createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.serviceId], foreignColumns: [cliPressServices.id] }).onDelete("cascade"), - uniqueIndex("uq_cli_press_credentials_service_name").on(t.serviceId, t.name), - index("idx_cli_press_credentials_service").on(t.serviceId, t.createdAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.serviceId], foreignColumns: [cliPressServices.projectId, cliPressServices.id] }).onDelete("cascade"), + uniqueIndex("uq_cli_press_credentials_service_name").on(t.projectId, t.serviceId, t.name), + index("idx_cli_press_credentials_service").on(t.projectId, t.serviceId, t.createdAt, t.id), ]); /** cli_press_service_settings — key/value settings scoped to a service. */ export const cliPressSettings = projectSchema.table("cli_press_service_settings", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), serviceId: text("service_id").notNull(), key: text("key").notNull(), value: text("value").notNull(), @@ -316,9 +339,10 @@ export const cliPressSettings = projectSchema.table("cli_press_service_settings" createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.serviceId], foreignColumns: [cliPressServices.id] }).onDelete("cascade"), - uniqueIndex("uq_cli_press_settings_service_key_scope").on(t.serviceId, t.key, t.scope), - index("idx_cli_press_settings_service").on(t.serviceId, t.createdAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.serviceId], foreignColumns: [cliPressServices.projectId, cliPressServices.id] }).onDelete("cascade"), + uniqueIndex("uq_cli_press_settings_service_key_scope").on(t.projectId, t.serviceId, t.key, t.scope), + index("idx_cli_press_settings_service").on(t.projectId, t.serviceId, t.createdAt, t.id), ]); /** diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index b68dc42451..cfaa311113 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -56,7 +56,8 @@ import { DatabaseSync } from "../sqlite-adapter.js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { sql } from "drizzle-orm"; import { createHash } from "node:crypto"; -import { basename } from "node:path"; +import { basename, dirname, resolve } from "node:path"; +import { existsSync } from "node:fs"; import { applySchemaBaseline } from "./schema-applier.js"; import { PROJECT_SCHEMA, @@ -87,6 +88,8 @@ export interface SqliteMigrationSource { readonly sqlitePath: string; /** The PostgreSQL schema this database maps to. */ readonly pgSchema: SchemaName; + /** Canonical owner of project-local rows that move into central state tables. */ + readonly projectPath?: string; } /** @@ -100,7 +103,7 @@ export interface SqliteMigrationSource { export function defaultMigrationSources(fusionDir: string, globalDir: string): readonly SqliteMigrationSource[] { return [ { sqlitePath: `${fusionDir}/archive.db`, pgSchema: ARCHIVE_SCHEMA }, - { sqlitePath: `${fusionDir}/fusion.db`, pgSchema: PROJECT_SCHEMA }, + { sqlitePath: `${fusionDir}/fusion.db`, pgSchema: PROJECT_SCHEMA, projectPath: resolve(dirname(fusionDir)) }, { sqlitePath: `${globalDir}/fusion-central.db`, pgSchema: CENTRAL_SCHEMA }, ]; } @@ -294,6 +297,8 @@ export interface MigrationOptions { readonly skipBaseline?: boolean; /** Project partition used when importing one project's legacy databases into a shared cluster. */ readonly projectId?: string; + /** Canonical filesystem path owning project-local plugin activation state. */ + readonly projectPath?: string; /** Durable identity used to serialize and record one project's cutover. */ readonly migrationKey?: string; /** Leave a verified migration running until caller-side project stamping succeeds. */ @@ -394,14 +399,23 @@ export async function migrateSqliteToPostgres( FNXC:PostgresMigrationSession 2026-07-14-00:05: Pin the complete cutover to one transaction-backed PostgreSQL session. Advisory locking, trigger deferral, copy, verification, and reset must not hop across connections when callers provide a multi-connection pool. */ - return await migrationDb.transaction((tx) => - migrateSqliteToPostgresOnSession( + return await migrationDb.transaction(async (tx) => { + const report = await migrateSqliteToPostgresOnSession( tx as unknown as PostgresJsDatabase>, sources, options, - ), - ); + ); + if (options.dryRun === true) { + /* + FNXC:PostgresMigration 2026-07-14-23:47: + A dry run may materialize the target schema inside its private transaction so column mapping can be planned against a pristine cluster, but the operator contract forbids any durable PostgreSQL change. Carry the completed report through a deliberate rollback instead of committing temporary DDL. + */ + throw new DryRunRollback(report); + } + return report; + }); } catch (error) { + if (error instanceof DryRunRollback) return error.report; const errorMessage = getErrorMessage(error); emitMigrationProgress(options, { phase: "failed", @@ -424,6 +438,13 @@ export async function migrateSqliteToPostgres( } } +class DryRunRollback extends Error { + constructor(readonly report: MigrationReport) { + super("SQLite migration dry run completed; rolling back target changes"); + this.name = "DryRunRollback"; + } +} + async function migrateSqliteToPostgresOnSession( migrationDb: PostgresJsDatabase>, sources: readonly SqliteMigrationSource[], @@ -459,9 +480,9 @@ async function migrateSqliteToPostgresOnSession( `); } - // 1. Apply the schema baseline (idempotent). In dry-run we still need to - // read the PostgreSQL column types, so the schema must exist. If the - // caller set skipBaseline, assume it's already there. + // 1. Apply the schema baseline (idempotent). A dry run creates it only inside + // the enclosing transaction, which is deliberately rolled back after the + // report is complete. If skipBaseline is set, assume it already exists. let appliedBaseline = false; try { if (!options.skipBaseline) { @@ -585,6 +606,22 @@ async function migrateSqliteToPostgresOnSession( } } } + if (!dryRun) { + for (const source of sources) { + if (source.pgSchema !== PROJECT_SCHEMA) continue; + const projectPath = source.projectPath ?? options.projectPath; + if (sqliteTableExists(source.sqlitePath, "plugins") && !projectPath) { + throw new Error(`projectPath is required to migrate legacy plugin state from ${source.sqlitePath}`); + } + if (projectPath) { + await migrateLegacyProjectPluginRowsOnSession( + migrationDb, + source.sqlitePath, + projectPath, + ); + } + } + } } catch (error) { copyError = error; } finally { @@ -673,6 +710,21 @@ async function buildMigrationPlan( const targetColumnsByTable = await loadTargetColumnMetadata(db, source.pgSchema); const plans: TablePlan[] = []; for (const table of tables) { + if (source.pgSchema === PROJECT_SCHEMA && table === "plugins") { + /* + FNXC:PluginLegacyMigration 2026-07-14-22:50: + Legacy plugin rows combine cluster-global installation metadata with project-path enablement state. Redirect them to the central plugin registry instead of copying into unpartitioned project.plugins, where identical plugin IDs from two projects would collide and lose one project's enabled/state values. + */ + plans.push({ + pgSchema: source.pgSchema, + table, + pgTable: table, + columns: [], + unmappedSourceColumns: [], + allowedSkipReason: "redirected to central plugin registry and project state", + }); + continue; + } const legacyPreservationTarget = source.pgSchema === PROJECT_SCHEMA ? LEGACY_PRESERVATION_TARGETS.get(table) : undefined; @@ -778,6 +830,140 @@ function openSqlite(path: string): DatabaseSync { return db; } +function sqliteTableExists(sqlitePath: string, table: string): boolean { + if (!existsSync(sqlitePath)) return false; + const db = openSqlite(sqlitePath); + try { + return Boolean(db.prepare( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`, + ).get(table)); + } finally { + db.close(); + } +} + +interface LegacyProjectPluginMigrationRow { + id: string; + name: string; + version: string; + description: string | null; + author: string | null; + homepage: string | null; + path: string; + enabled: number | null; + state: string | null; + settings: string | null; + settingsSchema: string | null; + error: string | null; + dependencies: string | null; + aiScanOnLoad: number | null; + lastSecurityScan: string | null; + createdAt: string; + updatedAt: string; +} + +function normalizeLegacyJson(value: string | null, fallback: string): string { + if (value === null || value.trim() === "") return fallback; + try { + return JSON.stringify(JSON.parse(value)); + } catch { + return fallback; + } +} + +/** Backfill the split PostgreSQL plugin model once from retained project SQLite. */ +export async function migrateLegacyProjectPluginRows( + db: PostgresJsDatabase>, + sqlitePath: string, + projectPath: string, +): Promise { + await db.transaction(async (tx) => { + await migrateLegacyProjectPluginRowsOnSession( + tx as unknown as PostgresJsDatabase>, + sqlitePath, + projectPath, + ); + }); +} + +async function migrateLegacyProjectPluginRowsOnSession( + db: PostgresJsDatabase>, + sqlitePath: string, + projectPath: string, +): Promise { + if (!sqliteTableExists(sqlitePath, "plugins")) return; + const canonicalProjectPath = resolve(projectPath); + const migrationKey = `project-plugins:${canonicalProjectPath}`; + await ensureMigrationStateTable(db); + await db.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${migrationKey}, 0))`); + const completed = (await db.execute(sql` + SELECT 1 AS complete + FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + WHERE migration_key = ${migrationKey} AND status = 'complete' + LIMIT 1 + `)) as unknown as Array<{ complete: number }>; + /* + FNXC:PluginLegacyMigration 2026-07-14-23:51: + Retained SQLite is immutable cutover evidence, not a recurring authority. Once a project's plugin rows have been split into PostgreSQL install metadata and path-scoped state, a durable marker prevents later edits to fusion.db from changing live plugin behavior on restart. + */ + if (completed.length > 0) return; + const sqlite = openSqlite(sqlitePath); + let rows: LegacyProjectPluginMigrationRow[]; + try { + rows = sqlite.prepare(`SELECT * FROM plugins ORDER BY id`).all() as LegacyProjectPluginMigrationRow[]; + } finally { + sqlite.close(); + } + for (const row of rows) { + const settings = normalizeLegacyJson(row.settings, "{}"); + const settingsSchema = row.settingsSchema == null ? null : normalizeLegacyJson(row.settingsSchema, "null"); + const dependencies = normalizeLegacyJson(row.dependencies, "[]"); + await db.execute(sql` + INSERT INTO central.plugin_installs + (id, name, version, description, author, homepage, path, settings, settings_schema, + dependencies, ai_scan_on_load, last_security_scan, created_at, updated_at) + VALUES + (${row.id}, ${row.name}, ${row.version}, ${row.description}, ${row.author}, ${row.homepage}, + ${row.path}, ${settings}::jsonb, ${settingsSchema}::jsonb, ${dependencies}::jsonb, + ${row.aiScanOnLoad ?? 0}, ${row.lastSecurityScan}, ${row.createdAt}, ${row.updatedAt}) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + version = EXCLUDED.version, + description = EXCLUDED.description, + author = EXCLUDED.author, + homepage = EXCLUDED.homepage, + path = EXCLUDED.path, + settings = EXCLUDED.settings, + settings_schema = EXCLUDED.settings_schema, + dependencies = EXCLUDED.dependencies, + ai_scan_on_load = EXCLUDED.ai_scan_on_load, + last_security_scan = EXCLUDED.last_security_scan, + updated_at = EXCLUDED.updated_at + WHERE EXCLUDED.updated_at > central.plugin_installs.updated_at + `); + await db.execute(sql` + INSERT INTO central.project_plugin_states + (project_path, plugin_id, enabled, state, error, created_at, updated_at) + VALUES + (${canonicalProjectPath}, ${row.id}, ${row.enabled ?? 1}, ${row.state ?? "installed"}, + ${row.error}, ${row.createdAt}, ${row.updatedAt}) + ON CONFLICT (project_path, plugin_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + state = EXCLUDED.state, + error = EXCLUDED.error, + updated_at = EXCLUDED.updated_at + WHERE EXCLUDED.updated_at > central.project_plugin_states.updated_at + `); + } + await db.execute(sql` + INSERT INTO public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + (migration_key, project_id, status, last_error, updated_at) + VALUES (${migrationKey}, NULL, 'complete', NULL, now()) + ON CONFLICT (migration_key) DO UPDATE + SET status = 'complete', last_error = NULL, updated_at = now() + `); +} + /** List every SQLite table so the migration report can account for all source data. */ function listSqliteTables(db: DatabaseSync): string[] { const rows = db diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index c1740a3755..21681864b0 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -548,6 +548,7 @@ export async function createTaskStoreForBackend( const report = await migrateSqliteToPostgres(connections.migration, sources, { skipBaseline: true, projectId: migrationProjectId, + projectPath: rootDir, migrationKey, deferCompletion: true, /* diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 64116fa36f..2dbe1aae79 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -245,9 +245,19 @@ export interface DashboardHealthResponse { lastCheckedAt: string | null; isRunning: boolean; }; - taskIdIntegrity: TaskIdIntegrityReport & { - recommendedAction: string | null; - }; + /* + FNXC:PostgresHealth 2026-07-14-23:45: + Health cannot label an unavailable PostgreSQL integrity detector as "ok". Preserve the existing report fields while exposing a distinct error state and diagnostic for the dashboard response contract. + */ + taskIdIntegrity: + | (TaskIdIntegrityReport & { recommendedAction: string | null }) + | { + status: "error"; + checkedAt: string; + anomalies: []; + error: string; + recommendedAction: string | null; + }; } export function fetchDashboardHealth(): Promise { diff --git a/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx b/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx index 43ff3b5c7e..6aa2defbf6 100644 --- a/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx +++ b/packages/dashboard/app/components/TaskIdIntegrityBanner.tsx @@ -2,13 +2,20 @@ import { AlertTriangle, RefreshCw } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { TaskIdIntegrityReport } from "@fusion/core"; -import { refreshDashboardHealth } from "../api"; +import { refreshDashboardHealth, type DashboardHealthResponse } from "../api"; import "./TaskIdIntegrityBanner.css"; +/* +FNXC:PostgresHealth 2026-07-14-23:58: +An on-demand integrity refresh may return a PostgreSQL detector error as well as an ok or anomaly report. Propagate the complete health contract so the dashboard cannot discard a failed readiness check after the banner requests a recheck. +*/ interface TaskIdIntegrityBannerProps { report: TaskIdIntegrityReport; recommendedAction: string; - onRefresh?: (report: TaskIdIntegrityReport, recommendedAction: string | null) => void; + onRefresh?: ( + report: DashboardHealthResponse["taskIdIntegrity"], + recommendedAction: string | null, + ) => void; } function getAnomalyLabel(kind: TaskIdIntegrityReport["anomalies"][number]["kind"], t: ReturnType["t"]): string { diff --git a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx index 242422d3ee..20524bc95b 100644 --- a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx +++ b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx @@ -144,7 +144,7 @@ export function DashboardBanners({ return { ...current, status: - report.status === "anomaly" + report.status !== "ok" || !current.database.healthy || current.database.corruptionDetected ? "degraded" diff --git a/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts b/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts new file mode 100644 index 0000000000..b0c88b904a --- /dev/null +++ b/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AsyncDataLayer, TaskStore } from "@fusion/core"; + +const healthMocks = vi.hoisted(() => ({ + checkPostgresHealth: vi.fn(), + detectTaskIdIntegrityAnomaliesAsync: vi.fn(), +})); + +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), + checkPostgresHealth: healthMocks.checkPostgresHealth, + detectTaskIdIntegrityAnomaliesAsync: healthMocks.detectTaskIdIntegrityAnomaliesAsync, +})); + +import { + evaluateDashboardPostgresHealth, + resolveDashboardPostgresLayer, +} from "../dashboard-postgres-health.js"; + +/* +FNXC:PostgresHealth 2026-07-14-23:45: +Dashboard health must derive the live PostgreSQL layer from TaskStore, fail closed when that layer is unavailable, and surface task-ID detector failures instead of converting them into an "ok" report. +*/ +describe("evaluateDashboardPostgresHealth", () => { + const layer = { db: {} } as AsyncDataLayer; + + beforeEach(() => { + vi.clearAllMocks(); + healthMocks.checkPostgresHealth.mockResolvedValue([]); + healthMocks.detectTaskIdIntegrityAnomaliesAsync.mockResolvedValue({ + status: "ok", + checkedAt: "2026-07-14T23:45:00.000Z", + anomalies: [], + }); + }); + + it("derives and probes the PostgreSQL layer owned by TaskStore", async () => { + const store = { getAsyncLayer: () => layer } as TaskStore; + + const result = await evaluateDashboardPostgresHealth(store); + + expect(healthMocks.checkPostgresHealth).toHaveBeenCalledWith(layer); + expect(healthMocks.detectTaskIdIntegrityAnomaliesAsync).toHaveBeenCalledWith(layer.db); + expect(result.database.healthy).toBe(true); + expect(result.taskIdIntegrity.status).toBe("ok"); + }); + + it("uses an explicit integration layer for health and compaction without consulting TaskStore", () => { + const getAsyncLayer = vi.fn(() => null); + const store = { getAsyncLayer } as unknown as TaskStore; + + expect(resolveDashboardPostgresLayer(store, layer)).toBe(layer); + expect(getAsyncLayer).not.toHaveBeenCalled(); + }); + + it("fails closed when no PostgreSQL layer is available", async () => { + const store = { getAsyncLayer: () => null } as TaskStore; + + const result = await evaluateDashboardPostgresHealth(store); + + expect(healthMocks.checkPostgresHealth).not.toHaveBeenCalled(); + expect(result.database).toMatchObject({ + healthy: false, + corruptionDetected: true, + corruptionErrors: ["PostgreSQL health layer unavailable"], + }); + expect(result.taskIdIntegrity).toMatchObject({ + status: "error", + error: "PostgreSQL health layer unavailable", + }); + }); + + it("degrades health when task-ID integrity detection throws", async () => { + const store = { getAsyncLayer: () => layer } as TaskStore; + healthMocks.detectTaskIdIntegrityAnomaliesAsync.mockRejectedValue(new Error("integrity query timed out")); + + const result = await evaluateDashboardPostgresHealth(store); + + expect(result.database).toMatchObject({ + healthy: false, + corruptionDetected: true, + corruptionErrors: ["PostgreSQL task-ID integrity check failed: integrity query timed out"], + }); + expect(result.taskIdIntegrity).toMatchObject({ + status: "error", + error: "PostgreSQL task-ID integrity check failed: integrity query timed out", + }); + }); +}); diff --git a/packages/dashboard/src/dashboard-postgres-health.ts b/packages/dashboard/src/dashboard-postgres-health.ts new file mode 100644 index 0000000000..0914035293 --- /dev/null +++ b/packages/dashboard/src/dashboard-postgres-health.ts @@ -0,0 +1,104 @@ +import { + checkPostgresHealth, + detectTaskIdIntegrityAnomaliesAsync, + type AsyncDataLayer, + type TaskIdIntegrityReport, + type TaskStore, +} from "@fusion/core"; + +export type DashboardTaskIdIntegrityHealth = + | TaskIdIntegrityReport + | { + status: "error"; + checkedAt: string; + anomalies: []; + error: string; + }; + +export interface DashboardPostgresHealthResult { + database: ReturnType; + taskIdIntegrity: DashboardTaskIdIntegrityHealth; +} + +/** Resolve the production TaskStore layer while retaining an explicit integration override. */ +export function resolveDashboardPostgresLayer( + store: TaskStore, + overrideLayer?: AsyncDataLayer, +): AsyncDataLayer | null { + return overrideLayer ?? store.getAsyncLayer(); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/* +FNXC:PostgresHealth 2026-07-14-23:45: +The dashboard health surface is a PostgreSQL readiness signal, not a legacy SQLite compatibility probe. Resolve the TaskStore-owned AsyncDataLayer by default, allow an explicit layer only as an integration override, and fail closed when connectivity or task-ID integrity cannot be verified. +*/ +export async function evaluateDashboardPostgresHealth( + store: TaskStore, + overrideLayer?: AsyncDataLayer, +): Promise { + const checkedAt = new Date(); + let layer: AsyncDataLayer | null = null; + try { + layer = resolveDashboardPostgresLayer(store, overrideLayer); + } catch (error) { + const message = `PostgreSQL health layer resolution failed: ${errorMessage(error)}`; + return failedHealth(checkedAt, message); + } + + if (!layer) return failedHealth(checkedAt, "PostgreSQL health layer unavailable"); + + const errors = await checkPostgresHealth(layer).catch((error: unknown) => [ + `PostgreSQL health check failed: ${errorMessage(error)}`, + ]); + if (errors.length > 0) return failedHealth(checkedAt, ...errors); + + try { + const taskIdIntegrity = await detectTaskIdIntegrityAnomaliesAsync(layer.db); + return { + database: healthyDatabase(checkedAt), + taskIdIntegrity, + }; + } catch (error) { + return failedHealth( + checkedAt, + `PostgreSQL task-ID integrity check failed: ${errorMessage(error)}`, + ); + } +} + +function healthyDatabase(checkedAt: Date): DashboardPostgresHealthResult["database"] { + return { + healthy: true, + corruptionDetected: false, + corruptionErrors: [], + lastCheckedAt: checkedAt, + isRunning: false, + }; +} + +function failedHealth( + checkedAt: Date, + ...errors: string[] +): DashboardPostgresHealthResult { + const visibleErrors = errors.slice(0, 5); + const error = visibleErrors.join("; "); + return { + database: { + healthy: false, + corruptionDetected: true, + corruptionErrors: visibleErrors, + lastCheckedAt: checkedAt, + isRunning: false, + }, + taskIdIntegrity: { + status: "error", + checkedAt: checkedAt.toISOString(), + anomalies: [], + error, + }, + }; +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index dd1be614fc..51f47e850b 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -14,7 +14,6 @@ import type { CentralCore, MessageStore, AgentLogEntry, - TaskIdIntegrityReport, RunAuditEvent, } from "@fusion/core"; import { AgentStore, ChatStore, queryRunAuditEvents, setRunningAgentCountSource } from "@fusion/core"; @@ -86,6 +85,11 @@ import { import { loadViewChunkManifest, type ViewChunkManifestEntry } from "./view-chunk-manifest.js"; import { maybeStartOtelExporter, type OtelExporterHandle } from "./otel-exporter.js"; import { requireAsyncLayer } from "./require-async-layer.js"; +import { + evaluateDashboardPostgresHealth, + resolveDashboardPostgresLayer, + type DashboardTaskIdIntegrityHealth, +} from "./dashboard-postgres-health.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -106,28 +110,31 @@ function parseVersion(version: string): number[] { .map((value) => (Number.isFinite(value) ? value : 0)); } -function buildTaskIdIntegrityHealth(report: TaskIdIntegrityReport) { +function buildTaskIdIntegrityHealth(report: DashboardTaskIdIntegrityHealth) { return { status: report.status, checkedAt: report.checkedAt, anomalies: report.anomalies, + ...(report.status === "error" ? { error: report.error } : {}), recommendedAction: report.status === "anomaly" ? "Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks." + : report.status === "error" + ? "Restore PostgreSQL connectivity and rerun the health check before creating new tasks." : null, }; } function buildHealthPayload(args: { database: ReturnType; - taskIdIntegrityReport: ReturnType; + taskIdIntegrityReport: DashboardTaskIdIntegrityHealth; cliPackageVersion: string; engineAvailable: boolean; }) { const { database, cliPackageVersion, engineAvailable } = args; const taskIdIntegrity = buildTaskIdIntegrityHealth(args.taskIdIntegrityReport); return { - status: !database.healthy || database.corruptionDetected || taskIdIntegrity.status === "anomaly" ? "degraded" : "ok", + status: !database.healthy || database.corruptionDetected || taskIdIntegrity.status !== "ok" ? "degraded" : "ok", version: cliPackageVersion, uptime: Math.floor(process.uptime()), /* @@ -513,13 +520,10 @@ export interface ServerOptions { }; /* * FNXC:PostgresHealth 2026-06-24-16:00: - * Optional PostgreSQL health layer. When provided, the /api/health and - * /api/health/refresh endpoints use PostgreSQL-native health checks - * (connectivity probe, schema drift, task-ID integrity) instead of the - * SQLite-specific integrity_check path. This is the integration seam - * between the async PostgreSQL data layer and the dashboard health surface. - * When absent, the endpoints fall back to the legacy SQLite health checks - * via store.getDatabaseHealth(). + * Optional PostgreSQL health-layer override for integration hosts and tests. + * Normal production servers derive the live layer from TaskStore. The + * health endpoints fail closed if neither source provides one; PostgreSQL + * health must never fall back to the backend-mode healthy sentinel. */ postgresHealthLayer?: import("@fusion/core").AsyncDataLayer; /* @@ -1710,41 +1714,17 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT * FNXC:PostgresHealth 2026-06-24-16:10: * The /api/health endpoint is async because PostgreSQL health checks * (connectivity probe, task-ID integrity via Drizzle) are inherently async. - * When postgresHealthLayer is provided, the endpoint uses PostgreSQL-native - * checks; otherwise it falls back to the legacy SQLite health checks. + * The endpoint derives the live PostgreSQL layer from TaskStore unless an + * integration host supplies an explicit override. Missing layers and failed + * connectivity/integrity queries return degraded health. * VAL-HEALTH-001: healthy backend reports green; VAL-HEALTH-002: corrupt/ * unreachable backend surfaces degraded status + errors. */ app.get("/api/health", async (_req, res) => { - const pgLayer = options?.postgresHealthLayer; - if (pgLayer) { - const { checkPostgresHealth } = await import("@fusion/core"); - const { detectTaskIdIntegrityAnomaliesAsync } = await import("@fusion/core"); - const errors = await checkPostgresHealth(pgLayer).catch((err: unknown) => [ - `PostgreSQL health check failed: ${err instanceof Error ? err.message : String(err)}`, - ]); - const integrityReport = await detectTaskIdIntegrityAnomaliesAsync(pgLayer.db).catch(() => ({ - status: "ok" as const, - checkedAt: new Date().toISOString(), - anomalies: [], - })); - res.json(buildHealthPayload({ - database: { - healthy: errors.length === 0, - corruptionDetected: errors.length > 0, - corruptionErrors: errors.slice(0, 5), - lastCheckedAt: new Date(), - isRunning: false, - }, - taskIdIntegrityReport: integrityReport, - cliPackageVersion, - engineAvailable: hasDashboardEngine(options), - })); - return; - } + const health = await evaluateDashboardPostgresHealth(store, options?.postgresHealthLayer); res.json(buildHealthPayload({ - database: store.getDatabaseHealth(), - taskIdIntegrityReport: store.getTaskIdIntegrityReport(), + database: health.database, + taskIdIntegrityReport: health.taskIdIntegrity, cliPackageVersion, engineAvailable: hasDashboardEngine(options), })); @@ -1942,40 +1922,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT app.post("/api/health/refresh", async (_req, res) => { /* * FNXC:PostgresHealth 2026-06-24-16:15: - * Force-recompute integrity + database health. When postgresHealthLayer - * is provided, uses PostgreSQL-native checks (VAL-HEALTH-002: clears stale - * corruption banner after repair). Otherwise falls back to the legacy - * SQLite refresh path. + * Force-recompute PostgreSQL connectivity and task-ID integrity through + * the live TaskStore layer (or an explicit integration override). Query + * failures remain visible as degraded health instead of healthy fallback. */ - const pgLayer = options?.postgresHealthLayer; - if (pgLayer) { - const { checkPostgresHealth } = await import("@fusion/core"); - const { detectTaskIdIntegrityAnomaliesAsync } = await import("@fusion/core"); - const errors = await checkPostgresHealth(pgLayer).catch((err: unknown) => [ - `PostgreSQL health check failed: ${err instanceof Error ? err.message : String(err)}`, - ]); - const integrityReport = await detectTaskIdIntegrityAnomaliesAsync(pgLayer.db).catch(() => ({ - status: "ok" as const, - checkedAt: new Date().toISOString(), - anomalies: [], - })); - res.json(buildHealthPayload({ - database: { - healthy: errors.length === 0, - corruptionDetected: errors.length > 0, - corruptionErrors: errors.slice(0, 5), - lastCheckedAt: new Date(), - isRunning: false, - }, - taskIdIntegrityReport: integrityReport, - cliPackageVersion, - engineAvailable: hasDashboardEngine(options), - })); - return; - } + const health = await evaluateDashboardPostgresHealth(store, options?.postgresHealthLayer); res.json(buildHealthPayload({ - database: store.refreshDatabaseHealth(), - taskIdIntegrityReport: store.refreshTaskIdIntegrityReport(), + database: health.database, + taskIdIntegrityReport: health.taskIdIntegrity, cliPackageVersion, engineAvailable: hasDashboardEngine(options), })); @@ -1984,13 +1938,27 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT /* * FNXC:PostgresHealth 2026-06-24-16:20: * Explicit compaction command: runs VACUUM/ANALYZE on the project-schema - * tables and reports per-table stats (VAL-HEALTH-005). Only available when - * the PostgreSQL health layer is provided; returns 501 otherwise. + * tables and reports per-table stats (VAL-HEALTH-005). Derive the production + * layer from TaskStore just like the read/refresh health routes; an explicit + * override remains available for integration hosts and tests. + * + * FNXC:PostgresHealth 2026-07-14-23:45: + * PostgreSQL compaction is a supported runtime capability. A missing layer + * is service unavailability, not an unimplemented endpoint, and must never + * be caused by production callers omitting an optional override. */ app.post("/api/health/compact", async (_req, res) => { - const pgLayer = options?.postgresHealthLayer; + let pgLayer: import("@fusion/core").AsyncDataLayer | null; + try { + pgLayer = resolveDashboardPostgresLayer(store, options?.postgresHealthLayer); + } catch (error) { + res.status(503).json({ + error: `PostgreSQL health layer resolution failed: ${error instanceof Error ? error.message : String(error)}`, + }); + return; + } if (!pgLayer) { - res.status(501).json({ error: "PostgreSQL compaction is not available (no postgresHealthLayer configured)." }); + res.status(503).json({ error: "PostgreSQL health layer unavailable for compaction." }); return; } try { diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index e9d2df24ef..8f9623a14f 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -498,10 +498,6 @@ describe("LocalRuntimeManager", () => { return server as unknown as Server; }), }); - engineMocks.pluginLoaderInstance.getPluginSchemaInitHooks.mockReturnValueOnce([ - { pluginId: "fusion-plugin-even-realities-glasses", hook: vi.fn() }, - ]); - const manager = new LocalRuntimeManager({ rootDir: "/repo", createStore: async () => store, @@ -515,10 +511,8 @@ describe("LocalRuntimeManager", () => { expect.objectContaining({ pluginStore: engineMocks.pluginStoreInstance, taskStore: expect.anything() }), ); expect(engineMocks.pluginLoaderInstance.loadAllPlugins).toHaveBeenCalledTimes(1); - /* FNXC:DesktopPluginSchema 2026-07-14-17:50: Desktop schema initialization goes through TaskStore and therefore cannot reach backend getDatabase(). */ - expect(engineMocks.runPluginSchemaInits).toHaveBeenCalledWith([ - expect.objectContaining({ pluginId: "fusion-plugin-even-realities-glasses" }), - ]); + /* FNXC:DesktopPluginSchema 2026-07-14-23:31: The host verifies single schema ownership by leaving execution to PluginLoader instead of replaying collected contracts. */ + expect(engineMocks.runPluginSchemaInits).not.toHaveBeenCalled(); expect(engineMocks.createServer).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index 8c171763fa..f2df3a07e4 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -303,10 +303,6 @@ describe("DesktopLocalServerManager", () => { it("wires PluginStore + PluginLoader into createServer (FN-7623)", async () => { const { DesktopLocalServerManager } = await import("../local-server.ts"); const manager = new DesktopLocalServerManager("/repo"); - mocks.pluginLoaderInstance.getPluginSchemaInitHooks.mockReturnValueOnce([ - { pluginId: "fusion-plugin-even-realities-glasses", hook: vi.fn() }, - ]); - await manager.start(); expect(mocks.store.getPluginStore).toHaveBeenCalledTimes(1); @@ -315,9 +311,8 @@ describe("DesktopLocalServerManager", () => { expect.objectContaining({ pluginStore: mocks.pluginStoreInstance, taskStore: expect.anything() }), ); expect(mocks.pluginLoaderInstance.loadAllPlugins).toHaveBeenCalledTimes(1); - expect(mocks.runPluginSchemaInits).toHaveBeenCalledWith([ - expect.objectContaining({ pluginId: "fusion-plugin-even-realities-glasses" }), - ]); + /* FNXC:DesktopPluginSchema 2026-07-14-23:31: The host verifies single schema ownership by leaving execution to PluginLoader instead of replaying collected contracts. */ + expect(mocks.runPluginSchemaInits).not.toHaveBeenCalled(); expect(mocks.createServer).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 98fa107b7b..ca51b35be3 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -277,11 +277,7 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin strace("createDashboardServer: pluginLoader.loadAllPlugins"); const { loaded, errors } = await pluginLoader.loadAllPlugins(); strace(`createDashboardServer: plugins loaded=${loaded} errors=${errors}`); - const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); - if (schemaHooks.length > 0) { - /* FNXC:DesktopPluginSchema 2026-07-14-17:30: Embedded desktop must not open the removed sync database in PostgreSQL mode; TaskStore selects the registered PG schema hook. */ - await store.runPluginSchemaInits(schemaHooks); - } + /* FNXC:DesktopPluginSchema 2026-07-14-23:31: PluginLoader runs backend-aware schema contracts before onLoad; embedded desktop must not replay them after loadAllPlugins. */ ensureBundledPluginInstalledCallback = async (pluginId: string): Promise => { if (!isBundledPluginId(pluginId)) { diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index df1572abd8..46043f204f 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -155,11 +155,7 @@ export class DesktopLocalServerManager { } await pluginLoader.loadAllPlugins(); - const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); - if (schemaHooks.length > 0) { - /* FNXC:DesktopPluginSchema 2026-07-14-17:30: Legacy desktop server delegates plugin schema work to TaskStore so PostgreSQL never calls getDatabase(). */ - await store.runPluginSchemaInits(schemaHooks); - } + /* FNXC:DesktopPluginSchema 2026-07-14-23:31: PluginLoader runs backend-aware schema contracts before onLoad; the legacy desktop host must not replay them after loadAllPlugins. */ ensureBundledPluginInstalledCallback = async (pluginId: string): Promise => { if (!isBundledPluginId(pluginId)) { diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index ae14449a0a..10268a925f 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -41,6 +41,8 @@ export type { PluginOnLoad, PluginOnUnload, PluginOnSchemaInit, + PluginOnPostgresSchemaInit, + PluginPostgresSchemaDefinition, PluginOnTaskCreated, PluginOnTaskMoved, PluginOnTaskCompleted, diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/cli-press-store.pg.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/cli-press-store.pg.test.ts index ef684378ca..353384e2ab 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/__tests__/cli-press-store.pg.test.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/cli-press-store.pg.test.ts @@ -7,6 +7,8 @@ */ import { describe, expect, it } from "vitest"; import { sql } from "drizzle-orm"; +import type { AsyncDataLayer } from "@fusion/core"; +import { cliPressPluginSchemaInit } from "../../../../packages/core/src/postgres/plugin-schema-hook.ts"; import { createTaskStoreForTest, pgDescribe, @@ -14,6 +16,10 @@ import { import { createCliPressStore } from "../store/cli-press-store.ts"; import { encodeCredentialValue } from "../store/credentials.ts"; +function projectLayer(layer: AsyncDataLayer, projectId = "cli-press-project-a"): AsyncDataLayer { + return { ...layer, projectId }; +} + pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { it("materializes all five cli_press_* tables via the schema-init hook", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_schema" }); @@ -43,10 +49,44 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { } }); + it("backfills a complete sentinel-owned hierarchy under existing composite foreign keys", async () => { + const h = await createTaskStoreForTest({ prefix: "fusion_clipress_upgrade_single" }); + try { + await h.adminDb.execute(sql.raw(` + /* FNXC:CliPressProjectIsolation 2026-07-14-22:48: Exercise the repeated-boot shape where composite foreign keys already exist and every preserved hierarchy row still carries the compatibility sentinel. */ + INSERT INTO central.projects(id, name, path, created_at, updated_at) + VALUES ('cli-project-only', 'Only', '/only', '2026-07-14', '2026-07-14'); + INSERT INTO project.cli_press_services(project_id, id, slug, display_name, base_url, source_kind, created_at, updated_at) + VALUES ('__legacy_unscoped__', 'svc-old', 'old', 'Old', 'https://old.example', 'manual', '2026-07-14', '2026-07-14'); + INSERT INTO project.cli_press_cli_specs(project_id, id, service_id, name, version, generator_version, spec_json, status, created_at, updated_at) + VALUES ('__legacy_unscoped__', 'spec-old', 'svc-old', 'old-cli', '1.0.0', 'legacy', '{}', 'draft', '2026-07-14', '2026-07-14'); + INSERT INTO project.cli_press_artifacts(project_id, id, cli_spec_id, kind, path, executable, created_at, updated_at) + VALUES ('__legacy_unscoped__', 'artifact-old', 'spec-old', 'script', 'old/bin', false, '2026-07-14', '2026-07-14'); + INSERT INTO project.cli_press_credentials(project_id, id, service_id, name, kind, value, placement, created_at, updated_at) + VALUES ('__legacy_unscoped__', 'credential-old', 'svc-old', 'token', 'header', '{}', '{}', '2026-07-14', '2026-07-14'); + INSERT INTO project.cli_press_service_settings(project_id, id, service_id, key, value, scope, created_at, updated_at) + VALUES ('__legacy_unscoped__', 'setting-old', 'svc-old', 'region', 'west', 'runtime', '2026-07-14', '2026-07-14'); + `)); + + await cliPressPluginSchemaInit.init(h.adminDb); + + const ownership = await h.adminDb.execute(sql.raw(` + SELECT project_id FROM project.cli_press_services WHERE id='svc-old' + UNION ALL SELECT project_id FROM project.cli_press_cli_specs WHERE id='spec-old' + UNION ALL SELECT project_id FROM project.cli_press_artifacts WHERE id='artifact-old' + UNION ALL SELECT project_id FROM project.cli_press_credentials WHERE id='credential-old' + UNION ALL SELECT project_id FROM project.cli_press_service_settings WHERE id='setting-old' + `)) as unknown as Array<{ project_id: string }>; + expect(ownership.map((row) => row.project_id)).toEqual(Array(5).fill("cli-project-only")); + } finally { + await h.teardown(); + } + }); + it("createService + getService + listServices round-trip", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_svc" }); try { - const store = createCliPressStore(null, h.layer); + const store = createCliPressStore(null, projectLayer(h.layer)); const created = await store.createService({ slug: "acme", displayName: "Acme Service", @@ -71,10 +111,40 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { } }); + it("isolates service definitions and credentials between two bound projects", async () => { + const h = await createTaskStoreForTest({ prefix: "fusion_clipress_isolation" }); + try { + const projectA = createCliPressStore(null, projectLayer(h.layer, "cli-press-project-a")); + const projectB = createCliPressStore(null, projectLayer(h.layer, "cli-press-project-b")); + const serviceA = await projectA.createService({ slug: "shared", displayName: "A", baseUrl: "https://a.example", sourceKind: "manual" }); + const serviceB = await projectB.createService({ slug: "shared", displayName: "B", baseUrl: "https://b.example", sourceKind: "manual" }); + await projectA.createCredential({ + serviceId: serviceA.id, + name: "token", + kind: "env_var", + placement: { kind: "env_var", envVar: "TOKEN" }, + value: encodeCredentialValue("project-a-secret"), + }); + + expect((await projectA.listServices()).map((service) => service.id)).toEqual([serviceA.id]); + expect((await projectB.listServices()).map((service) => service.id)).toEqual([serviceB.id]); + expect(await projectB.listCredentials(serviceA.id)).toEqual([]); + await expect(projectB.createCredential({ + serviceId: serviceA.id, + name: "stolen", + kind: "env_var", + placement: { kind: "env_var", envVar: "STOLEN" }, + value: encodeCredentialValue("nope"), + })).rejects.toThrow(); + } finally { + await h.teardown(); + } + }); + it("updateService mutates only the allowed fields", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_svc_upd" }); try { - const store = createCliPressStore(null, h.layer); + const store = createCliPressStore(null, projectLayer(h.layer)); const created = await store.createService({ slug: "beta", displayName: "Beta", @@ -101,7 +171,7 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { it("spec, artifact, and setting CRUD round-trip with boolean executable", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_spec" }); try { - const store = createCliPressStore(null, h.layer); + const store = createCliPressStore(null, projectLayer(h.layer)); const service = await store.createService({ slug: "gamma", displayName: "Gamma", @@ -155,6 +225,35 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { }); const settingsAfterUpsert = await store.listSettings(service.id); expect(settingsAfterUpsert).toHaveLength(1); + + const concurrent = await Promise.all(Array.from({ length: 8 }, (_, index) => store.setSetting({ + serviceId: service.id, + key: "parallel", + value: String(index), + scope: "wizard", + }))); + expect(new Set(concurrent.map((setting) => setting.id)).size).toBe(1); + expect((await store.listSettings(service.id)).filter((setting) => setting.key === "parallel")).toHaveLength(1); + + const generated = await store.updateSpec(spec.id, { status: "generated", generatedAt: new Date().toISOString() }); + await store.createSpec({ + serviceId: service.id, + name: "draft-history", + version: "1.0.0", + generatorVersion: "cli-printing-press", + specJson: "{}", + status: "draft", + }); + await store.createArtifact({ + cliSpecId: generated.id, + kind: "metadata", + path: "plugins/cli-printing-press/artifacts/gamma/metadata.json", + executable: false, + }); + expect((await store.listGeneratedSpecs()).map((row) => row.id)).toContain(generated.id); + expect((await store.listGeneratedSpecs()).some((row) => row.name === "draft-history")).toBe(false); + expect((await store.listExecutableArtifacts()).map((row) => row.id)).toContain(artifact.id); + expect((await store.listExecutableArtifacts()).every((row) => row.executable)).toBe(true); } finally { await h.teardown(); } @@ -163,7 +262,7 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { it("credential value/placement JSON round-trips and rejects oauth", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_cred" }); try { - const store = createCliPressStore(null, h.layer); + const store = createCliPressStore(null, projectLayer(h.layer)); const service = await store.createService({ slug: "delta", displayName: "Delta", @@ -212,7 +311,7 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { it("deleteService cascades to child specs, artifacts, credentials, and settings", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_cascade" }); try { - const store = createCliPressStore(null, h.layer); + const store = createCliPressStore(null, projectLayer(h.layer)); const service = await store.createService({ slug: "epsilon", displayName: "Epsilon", @@ -268,7 +367,7 @@ pgDescribe("CliPressStore (PostgreSQL / backend mode)", () => { it("updateSpec and deleteSpec operate on the spec row", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_clipress_spec_mut" }); try { - const store = createCliPressStore(null, h.layer); + const store = createCliPressStore(null, projectLayer(h.layer)); const service = await store.createService({ slug: "zeta", displayName: "Zeta", diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/executor-runtime-env.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/executor-runtime-env.test.ts new file mode 100644 index 0000000000..064df3b212 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/executor-runtime-env.test.ts @@ -0,0 +1,61 @@ +import { expect, it, vi } from "vitest"; +import { buildExecutorRuntimeEnv } from "../runtime/executor-runtime-env.js"; +import type { CliPressStore } from "../store/cli-press-store.js"; + +it("builds every service environment with a constant four-query filtered catalog load", async () => { + const now = new Date().toISOString(); + const services = ["one", "two"].map((id) => ({ + id, + slug: id, + displayName: id, + description: undefined, + baseUrl: "https://example.test", + sourceKind: "manual" as const, + sourceRef: undefined, + createdAt: now, + updatedAt: now, + })); + const listServices = vi.fn().mockResolvedValue(services); + const listGeneratedSpecs = vi.fn().mockResolvedValue(services.map((service) => ({ + id: `spec-${service.id}`, + serviceId: service.id, + name: service.id, + version: "1", + generatorVersion: "1", + specJson: "{}", + generatedAt: now, + status: "generated", + lastGenerationError: undefined, + createdAt: now, + updatedAt: now, + }))); + const listExecutableArtifacts = vi.fn().mockResolvedValue([]); + const listAllCredentials = vi.fn().mockResolvedValue(services.map((service) => ({ + id: `credential-${service.id}`, + serviceId: service.id, + name: service.id, + kind: "env_var", + value: { encoding: "base64", value: Buffer.from(service.id).toString("base64") }, + placement: { kind: "env_var", envVar: `SERVICE_${service.id.toUpperCase()}` }, + createdAt: now, + updatedAt: now, + }))); + const store = { + listServices, + listGeneratedSpecs, + listExecutableArtifacts, + listAllCredentials, + listSpecs: vi.fn(() => { throw new Error("per-service query must not run"); }), + listArtifacts: vi.fn(() => { throw new Error("per-spec query must not run"); }), + listCredentials: vi.fn(() => { throw new Error("per-service query must not run"); }), + } as unknown as CliPressStore; + + const result = await buildExecutorRuntimeEnv( + store, + { taskId: "FN-1", rootDir: "/tmp/fusion-printing-press-test", worktreePath: "/tmp/fusion-printing-press-test" }, + { logger: { warn: vi.fn() } } as never, + ); + + expect(result.env).toEqual({ SERVICE_ONE: "one", SERVICE_TWO: "two" }); + expect([listServices, listGeneratedSpecs, listExecutableArtifacts, listAllCredentials].every((fn) => fn.mock.calls.length === 1)).toBe(true); +}); diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/tools.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/tools.test.ts new file mode 100644 index 0000000000..b89edd1751 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/tools.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk"; +import plugin from "../index.js"; +import { createCliPrintingPressTools } from "../tools.js"; + +function context(): PluginContext { + return { + pluginId: "fusion-plugin-cli-printing-press", + taskStore: {} as PluginContext["taskStore"], + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + }; +} + +describe("CLI Printing Press agent tools", () => { + it("registers validated CRUD, generation, test primitives, and prompt vocabulary", () => { + expect(plugin.tools?.map((tool) => tool.name)).toEqual([ + "cli_press_list", + "cli_press_get", + "cli_press_create", + "cli_press_update", + "cli_press_delete", + "cli_press_generate", + "cli_press_test", + ]); + expect(plugin.promptContributions?.enabledByDefault).toBe(true); + expect(plugin.promptContributions?.contributions.map((item) => item.surface)).toEqual([ + "executor-system", + "executor-task", + ]); + }); + + it("passes the complete definition through the existing create route and returns its entity", async () => { + const draft = { id: "svc_1", name: "Weather", slug: "weather", endpoints: [] }; + const handler = vi.fn(async (request: unknown) => { + expect(request).toEqual({ params: {}, body: draft }); + return { status: 201, body: { ...draft, status: "draft" } }; + }); + const routes: PluginRouteDefinition[] = [{ method: "POST", path: "/drafts", handler }]; + const tool = createCliPrintingPressTools(routes).find((candidate) => candidate.name === "cli_press_create"); + + const result = await tool!.execute({ draft }, context()); + + expect(result.isError).toBe(false); + expect(result.details).toEqual({ status: 201, body: { ...draft, status: "draft" } }); + }); + + it("delegates bounded test inputs and preserves validation failures", async () => { + const handler = vi.fn(async (request: unknown) => { + expect(request).toEqual({ + params: { id: "svc_1" }, + body: { endpointId: "forecast", params: { days: 3 }, credentials: undefined, timeoutMs: 5000 }, + }); + return { status: 409, body: { error: "Draft has not been generated yet" } }; + }); + const routes: PluginRouteDefinition[] = [{ method: "POST", path: "/drafts/:id/run", handler }]; + const tool = createCliPrintingPressTools(routes).find((candidate) => candidate.name === "cli_press_test"); + + const result = await tool!.execute({ id: "svc_1", endpointId: "forecast", params: { days: 3 }, timeoutMs: 5000 }, context()); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toBe("Draft has not been generated yet"); + expect(result.details).toEqual({ status: 409, body: { error: "Draft has not been generated yet" } }); + }); +}); diff --git a/plugins/fusion-plugin-cli-printing-press/src/index.ts b/plugins/fusion-plugin-cli-printing-press/src/index.ts index d83349d040..1169745952 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/index.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/index.ts @@ -4,36 +4,26 @@ import { createCliPrintingPressRoutes } from "./routes/wizard-routes.js"; import { buildExecutorRuntimeEnv } from "./runtime/executor-runtime-env.js"; import { createCliPressStore, ensureCliPressSchema, type CliPressStore } from "./store/cli-press-store.js"; import { CLI_PRINTING_PRESS_WORKFLOW_STEPS } from "./workflow-steps.js"; +import { cliPrintingPressTools } from "./tools.js"; + +/* +FNXC:CliPrintingPressAgentVocabulary 2026-07-14-18:47: +CLI definitions are an agent-native project domain. Prompt contributions teach agents to use validated CRUD, generation, and bounded test primitives and to report persisted definition and artifact state rather than editing plugin tables or generated files directly. +*/ interface TaskStoreLike { - getDatabase(): object; - isBackendMode(): boolean; getAsyncLayer(): AsyncDataLayer | null; } -// Cache keyed by the SQLite db object (legacy mode). In backend mode the store -// is cached by the TaskStore instance instead (the async layer is stable per -// TaskStore), so a null-db store is never cached here. -const storeByDb = new WeakMap(); const storeByTaskStore = new WeakMap(); function getStore(taskStore: TaskStoreLike): CliPressStore { - // FNXC:PostgresCutover 2026-07-04-00:00: - // Dual-mode: in backend mode pass the AsyncDataLayer so the store routes to - // Drizzle queries against the plugin-owned PG tables (materialized by the - // cliPressPluginSchemaInit hook). Legacy SQLite mode passes the sync db. - if (taskStore.isBackendMode()) { - const cached = storeByTaskStore.get(taskStore as object); - if (cached) return cached; - const next = createCliPressStore(null, taskStore.getAsyncLayer()); - storeByTaskStore.set(taskStore as object, next); - return next; - } - const db = taskStore.getDatabase(); - const existing = storeByDb.get(db); - if (existing) return existing; - const next = createCliPressStore(db as never); - storeByDb.set(db, next); + const cached = storeByTaskStore.get(taskStore as object); + if (cached) return cached; + const asyncLayer = taskStore.getAsyncLayer(); + if (!asyncLayer) throw new Error("CLI Printing Press plugin requires the project PostgreSQL AsyncDataLayer"); + const next = createCliPressStore(null, asyncLayer); + storeByTaskStore.set(taskStore as object, next); return next; } @@ -44,12 +34,27 @@ const plugin = definePlugin({ version: "0.1.0", description: "Guided wizard for drafting external service CLI definitions", workflowSteps: CLI_PRINTING_PRESS_WORKFLOW_STEPS.map((step) => ({ stepId: step.stepId, name: step.name })), + promptSurfaces: ["executor-system", "executor-task"], }, state: "installed", hooks: { onSchemaInit: ensureCliPressSchema, }, routes: createCliPrintingPressRoutes(), + tools: cliPrintingPressTools, + promptContributions: { + enabledByDefault: true, + contributions: [ + { + surface: "executor-system", + content: "CLI Printing Press definitions are project-scoped ServiceDraft records. Use cli_press_list/get/create/update/delete for validated persistence, cli_press_generate for artifacts, and cli_press_test for bounded endpoint verification. Never edit plugin tables or generated artifacts directly.", + }, + { + surface: "executor-task", + content: "For CLI definition work, return the persisted definition id and validation state; after generation or testing also return artifact metadata or test exit status.", + }, + ], + }, executorRuntimeEnv: async (taskCtx, ctx) => { const store = getStore(ctx.taskStore as TaskStoreLike); return buildExecutorRuntimeEnv(store, taskCtx, ctx); @@ -79,4 +84,5 @@ export default plugin; export { createCliPressStore, ensureCliPressSchema } from "./store/cli-press-store.js"; export type { CliPressStore } from "./store/cli-press-store.js"; export { CLI_PRINTING_PRESS_WORKFLOW_STEPS } from "./workflow-steps.js"; +export { createCliPrintingPressTools, cliPrintingPressTools } from "./tools.js"; export * from "./store/cli-press-types.js"; diff --git a/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts b/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts index b1fd2e7be6..13250d74c2 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts @@ -31,14 +31,10 @@ function getArtifactDir(id: string, projectRoot: string): string { } function getStore(ctx: PluginContext) { - // FNXC:PostgresCutover 2026-07-04-00:00: - // Dual-mode: in backend mode pass the AsyncDataLayer so the store routes to - // Drizzle queries against the plugin-owned PG tables; legacy SQLite mode - // passes the sync db. - const backendMode = ctx.taskStore.isBackendMode(); - const db = backendMode ? null : ctx.taskStore.getDatabase(); - const asyncLayer = backendMode ? ctx.taskStore.getAsyncLayer() : null; - return createCliPressStore(db, asyncLayer); + const asyncLayer = ctx.taskStore.getAsyncLayer(); + if (!asyncLayer) throw new Error("CLI Printing Press routes require the project PostgreSQL AsyncDataLayer"); + /* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Printing Press routes persist exclusively through plugin-owned PostgreSQL tables. */ + return createCliPressStore(null, asyncLayer); } function toDraft(service: Service, spec: CliSpec | undefined, endpoints: ServiceDraft["endpoints"]): ServiceDraft { diff --git a/plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts b/plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts index 5e6fb27a33..8199f04f35 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts @@ -17,15 +17,28 @@ export async function buildExecutorRuntimeEnv( ): Promise { const pathDirs: string[] = []; const env: Record = {}; + /* + FNXC:CliPrintingPressRuntime 2026-07-14-23:53: + Build the runtime catalog with four fixed, SQL-filtered queries, then join in memory. Per-service fanout and loading draft specs or non-executable artifact history both add dispatch latency without contributing to the task environment. + */ + const [services, specs, artifacts, credentials] = await Promise.all([ + store.listServices(), + store.listGeneratedSpecs(), + store.listExecutableArtifacts(), + store.listAllCredentials(), + ]); + const specsByService = groupBy(specs, (spec) => spec.serviceId); + const artifactsBySpec = groupBy(artifacts, (artifact) => artifact.cliSpecId); + const credentialsByService = groupBy(credentials, (credential) => credential.serviceId); - for (const service of await store.listServices()) { - const specs = (await store.listSpecs(service.id)) - .filter((spec) => spec.status === "generated") + for (const service of services) { + const serviceSpecs = (specsByService.get(service.id) ?? []) .sort((a, b) => toEpoch(b.generatedAt ?? b.updatedAt) - toEpoch(a.generatedAt ?? a.updatedAt)); - const selectedSpec = await findExecutableSpec(store, specs); + const selectedSpec = serviceSpecs.find((spec) => + (artifactsBySpec.get(spec.id) ?? []).length > 0); if (selectedSpec) { - const executableArtifacts = (await store.listArtifacts(selectedSpec.id)).filter((artifact) => artifact.executable); + const executableArtifacts = artifactsBySpec.get(selectedSpec.id) ?? []; for (const artifact of executableArtifacts) { const absoluteArtifactPath = isAbsolute(artifact.path) ? artifact.path @@ -40,7 +53,7 @@ export async function buildExecutorRuntimeEnv( } } - for (const credential of await store.listCredentials(service.id)) { + for (const credential of credentialsByService.get(service.id) ?? []) { const credentialKind = (credential as { kind: string }).kind; if (credentialKind === "oauth" || credentialKind === "oauth2") { throw new Error(`OAuth credentials are not supported for service ${service.slug}`); @@ -67,19 +80,13 @@ export async function buildExecutorRuntimeEnv( }; } -/** - * First spec (in the given order) that owns at least one executable artifact. - * Pulled out so the loop body stays flat; returns undefined when none qualify. - */ -async function findExecutableSpec( - store: CliPressStore, - specs: { id: string }[], -): Promise<{ id: string } | undefined> { - for (const spec of specs) { - const artifacts = await store.listArtifacts(spec.id); - if (artifacts.some((artifact) => artifact.executable)) { - return spec; - } +function groupBy(values: readonly T[], keyOf: (value: T) => string): Map { + const grouped = new Map(); + for (const value of values) { + const key = keyOf(value); + const bucket = grouped.get(key); + if (bucket) bucket.push(value); + else grouped.set(key, [value]); } - return undefined; + return grouped; } diff --git a/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts b/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts index db1eb52af2..1e43d736f9 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts @@ -141,15 +141,20 @@ export interface CliPressStore { updateService(id: string, updates: ServiceUpdateInput): Promise; deleteService(id: string): Promise; listSpecs(serviceId: string): Promise; + listAllSpecs(): Promise; + listGeneratedSpecs(): Promise; getSpec(id: string): Promise; createSpec(input: CliSpecCreateInput): Promise; updateSpec(id: string, updates: CliSpecUpdateInput): Promise; deleteSpec(id: string): Promise; listArtifacts(specId: string): Promise; + listAllArtifacts(): Promise; + listExecutableArtifacts(): Promise; createArtifact(input: CliArtifactCreateInput): Promise; updateArtifact(id: string, updates: CliArtifactUpdateInput): Promise; deleteArtifact(id: string): Promise; listCredentials(serviceId: string): Promise; + listAllCredentials(): Promise; createCredential(input: CredentialCreateInput): Promise; updateCredential(id: string, updates: CredentialUpdateInput): Promise; deleteCredential(id: string): Promise; @@ -322,6 +327,12 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL if (!db) throw new Error("CliPressStore: sync Database is null (backend mode)"); return db; }; + /** FNXC:CliPressProjectIsolation 2026-07-14-21:28: Every PostgreSQL definition, artifact, setting, and credential belongs to the AsyncDataLayer project; explicit predicates enforce the boundary on every store operation. */ + const projectId = (): string => { + const id = asyncLayer?.projectId?.trim(); + if (!id) throw new Error("CliPressStore: PostgreSQL backend requires asyncLayer.projectId"); + return id; + }; return { async listServices(): Promise { @@ -329,6 +340,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressServices) + .where(eq(cliPressServices.projectId, projectId())) .orderBy(desc(cliPressServices.createdAt)); return (rows as ServiceRow[]).map(mapService); } @@ -341,7 +353,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressServices) - .where(eq(cliPressServices.id, id)) + .where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))) .limit(1); return rows[0] ? mapService(rows[0] as ServiceRow) : undefined; } @@ -353,6 +365,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const service: Service = { id: createId("svc"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; if (asyncLayer) { await asyncLayer.db.insert(cliPressServices).values({ + projectId: projectId(), id: service.id, slug: service.slug, displayName: service.displayName, @@ -384,7 +397,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL sourceKind: updated.sourceKind, sourceRef: updated.sourceRef ?? null, updatedAt: updated.updatedAt, - }).where(eq(cliPressServices.id, id)); + }).where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))); return updated; } syncDb().prepare(`UPDATE cli_press_services SET displayName = ?, description = ?, baseUrl = ?, sourceKind = ?, sourceRef = ?, updatedAt = ? WHERE id = ?`) @@ -396,7 +409,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL async deleteService(id: string): Promise { if (asyncLayer) { // FK ON DELETE CASCADE removes child specs/artifacts/credentials/settings. - await asyncLayer.db.delete(cliPressServices).where(eq(cliPressServices.id, id)); + await asyncLayer.db.delete(cliPressServices).where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))); return; } syncDb().transaction(() => { @@ -410,7 +423,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressSpecs) - .where(eq(cliPressSpecs.serviceId, serviceId)) + .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.serviceId, serviceId))) .orderBy(desc(cliPressSpecs.createdAt)); return (rows as CliSpecRow[]).map(mapSpec); } @@ -418,12 +431,38 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL return rows.map(mapSpec); }, + /* + FNXC:CliPrintingPressRuntime 2026-07-14-18:45: + Executor environment construction runs for every dispatched task. Fetch each plugin table once so PostgreSQL round trips remain constant as services and historical specs grow. + */ + async listAllSpecs(): Promise { + if (asyncLayer) { + const rows = await asyncLayer.db.select().from(cliPressSpecs).where(eq(cliPressSpecs.projectId, projectId())).orderBy(desc(cliPressSpecs.createdAt)); + return (rows as CliSpecRow[]).map(mapSpec); + } + const rows = syncDb().prepare("SELECT * FROM cli_press_cli_specs ORDER BY createdAt DESC").all() as unknown as CliSpecRow[]; + return rows.map(mapSpec); + }, + + /** FNXC:CliPrintingPressRuntime 2026-07-14-23:53: Executor dispatch only consumes generated definitions; filter them in the owning database instead of transferring draft and failed history into every task launch. */ + async listGeneratedSpecs(): Promise { + if (asyncLayer) { + const rows = await asyncLayer.db.select().from(cliPressSpecs) + .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.status, "generated"))) + .orderBy(desc(cliPressSpecs.createdAt)); + return (rows as CliSpecRow[]).map(mapSpec); + } + const rows = syncDb().prepare("SELECT * FROM cli_press_cli_specs WHERE status = ? ORDER BY createdAt DESC") + .all("generated") as unknown as CliSpecRow[]; + return rows.map(mapSpec); + }, + async getSpec(id: string): Promise { if (asyncLayer) { const rows = await asyncLayer.db .select() .from(cliPressSpecs) - .where(eq(cliPressSpecs.id, id)) + .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))) .limit(1); return rows[0] ? mapSpec(rows[0] as CliSpecRow) : undefined; } @@ -435,6 +474,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const spec: CliSpec = { id: createId("cli"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; if (asyncLayer) { await asyncLayer.db.insert(cliPressSpecs).values({ + projectId: projectId(), id: spec.id, serviceId: spec.serviceId, name: spec.name, @@ -470,7 +510,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL status: updated.status, lastGenerationError: updated.lastGenerationError ?? null, updatedAt: updated.updatedAt, - }).where(eq(cliPressSpecs.id, id)); + }).where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))); return updated; } syncDb().prepare(`UPDATE cli_press_cli_specs SET name=?, version=?, generatorVersion=?, specJson=?, generatedAt=?, status=?, lastGenerationError=?, updatedAt=? WHERE id=?`) @@ -481,7 +521,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL async deleteSpec(id: string): Promise { if (asyncLayer) { - await asyncLayer.db.delete(cliPressSpecs).where(eq(cliPressSpecs.id, id)); + await asyncLayer.db.delete(cliPressSpecs).where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))); return; } syncDb().prepare("DELETE FROM cli_press_cli_specs WHERE id = ?").run(id); @@ -493,7 +533,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressArtifacts) - .where(eq(cliPressArtifacts.cliSpecId, specId)) + .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.cliSpecId, specId))) .orderBy(desc(cliPressArtifacts.createdAt)); return (rows as CliArtifactRow[]).map(mapArtifact); } @@ -501,10 +541,33 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL return rows.map(mapArtifact); }, + async listAllArtifacts(): Promise { + if (asyncLayer) { + const rows = await asyncLayer.db.select().from(cliPressArtifacts).where(eq(cliPressArtifacts.projectId, projectId())).orderBy(desc(cliPressArtifacts.createdAt)); + return (rows as CliArtifactRow[]).map(mapArtifact); + } + const rows = syncDb().prepare("SELECT * FROM cli_press_artifacts ORDER BY createdAt DESC").all() as unknown as CliArtifactRow[]; + return rows.map(mapArtifact); + }, + + /** FNXC:CliPrintingPressRuntime 2026-07-14-23:53: Runtime PATH construction needs executable artifacts only; keep non-executable generation history out of the dispatch catalog at the SQL boundary. */ + async listExecutableArtifacts(): Promise { + if (asyncLayer) { + const rows = await asyncLayer.db.select().from(cliPressArtifacts) + .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.executable, true))) + .orderBy(desc(cliPressArtifacts.createdAt)); + return (rows as CliArtifactRow[]).map(mapArtifact); + } + const rows = syncDb().prepare("SELECT * FROM cli_press_artifacts WHERE executable = ? ORDER BY createdAt DESC") + .all(1) as unknown as CliArtifactRow[]; + return rows.map(mapArtifact); + }, + async createArtifact(input: CliArtifactCreateInput): Promise { const artifact: CliArtifact = { id: createId("art"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; if (asyncLayer) { await asyncLayer.db.insert(cliPressArtifacts).values({ + projectId: projectId(), id: artifact.id, cliSpecId: artifact.cliSpecId, kind: artifact.kind, @@ -529,7 +592,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressArtifacts) - .where(eq(cliPressArtifacts.id, id)) + .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))) .limit(1); const existing = rows[0] as CliArtifactRow | undefined; if (!existing) throw new Error(`Artifact ${id} not found`); @@ -547,7 +610,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL checksum: updated.checksum ?? null, sizeBytes: updated.sizeBytes ?? null, updatedAt: updated.updatedAt, - }).where(eq(cliPressArtifacts.id, id)); + }).where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))); return updated; } const existing = syncDb().prepare("SELECT * FROM cli_press_artifacts WHERE id = ?").get(id) as unknown as CliArtifactRow | undefined; @@ -561,7 +624,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL async deleteArtifact(id: string): Promise { if (asyncLayer) { - await asyncLayer.db.delete(cliPressArtifacts).where(eq(cliPressArtifacts.id, id)); + await asyncLayer.db.delete(cliPressArtifacts).where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))); return; } syncDb().prepare("DELETE FROM cli_press_artifacts WHERE id = ?").run(id); @@ -573,7 +636,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressCredentials) - .where(eq(cliPressCredentials.serviceId, serviceId)) + .where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.serviceId, serviceId))) .orderBy(desc(cliPressCredentials.createdAt)); return (rows as CredentialRow[]).map(mapCredential); } @@ -581,12 +644,22 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL return rows.map(mapCredential); }, + async listAllCredentials(): Promise { + if (asyncLayer) { + const rows = await asyncLayer.db.select().from(cliPressCredentials).where(eq(cliPressCredentials.projectId, projectId())).orderBy(desc(cliPressCredentials.createdAt)); + return (rows as CredentialRow[]).map(mapCredential); + } + const rows = syncDb().prepare("SELECT * FROM cli_press_credentials ORDER BY createdAt DESC").all() as unknown as CredentialRow[]; + return rows.map(mapCredential); + }, + async createCredential(input: CredentialCreateInput): Promise { assertCredentialSupported(input.kind); assertPlacementConsistency(input.kind, input.placement); const cred: Credential = { id: createId("cred"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; if (asyncLayer) { await asyncLayer.db.insert(cliPressCredentials).values({ + projectId: projectId(), id: cred.id, serviceId: cred.serviceId, name: cred.name, @@ -610,7 +683,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressCredentials) - .where(eq(cliPressCredentials.id, id)) + .where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))) .limit(1); const existing = rows[0] as CredentialRow | undefined; if (!existing) throw new Error(`Credential ${id} not found`); @@ -631,7 +704,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL value: JSON.stringify(updated.value), placement: JSON.stringify(updated.placement), updatedAt: updated.updatedAt, - }).where(eq(cliPressCredentials.id, id)); + }).where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))); return updated; } const existing = syncDb().prepare("SELECT * FROM cli_press_credentials WHERE id = ?").get(id) as unknown as CredentialRow | undefined; @@ -656,7 +729,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL async deleteCredential(id: string): Promise { if (asyncLayer) { - await asyncLayer.db.delete(cliPressCredentials).where(eq(cliPressCredentials.id, id)); + await asyncLayer.db.delete(cliPressCredentials).where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))); return; } syncDb().prepare("DELETE FROM cli_press_credentials WHERE id = ?").run(id); @@ -668,7 +741,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const rows = await asyncLayer.db .select() .from(cliPressSettings) - .where(eq(cliPressSettings.serviceId, serviceId)) + .where(and(eq(cliPressSettings.projectId, projectId()), eq(cliPressSettings.serviceId, serviceId))) .orderBy(desc(cliPressSettings.createdAt)); return (rows as ServiceSettingRow[]).map(mapSetting); } @@ -679,18 +752,13 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL async setSetting(input: ServiceSettingCreateInput): Promise { const now = nowIso(); if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressSettings) - .where(and(eq(cliPressSettings.serviceId, input.serviceId), eq(cliPressSettings.key, input.key), eq(cliPressSettings.scope, input.scope))) - .limit(1); - const existing = rows[0] as ServiceSettingRow | undefined; - if (existing) { - await asyncLayer.db.update(cliPressSettings).set({ value: input.value, updatedAt: now }).where(eq(cliPressSettings.id, existing.id)); - return mapSetting({ ...existing, value: input.value, updatedAt: now }); - } + /* + FNXC:CliPrintingPressConcurrency 2026-07-14-23:53: + Settings are unique by project, service, key, and scope. Resolve concurrent first writes with one PostgreSQL conflict upsert so callers cannot race between SELECT and INSERT or create a transient uniqueness failure. + */ const setting: ServiceSetting = { id: createId("set"), ...input, createdAt: now, updatedAt: now }; - await asyncLayer.db.insert(cliPressSettings).values({ + const rows = await asyncLayer.db.insert(cliPressSettings).values({ + projectId: projectId(), id: setting.id, serviceId: setting.serviceId, key: setting.key, @@ -698,8 +766,11 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL scope: setting.scope, createdAt: setting.createdAt, updatedAt: setting.updatedAt, - }); - return setting; + }).onConflictDoUpdate({ + target: [cliPressSettings.projectId, cliPressSettings.serviceId, cliPressSettings.key, cliPressSettings.scope], + set: { value: input.value, updatedAt: now }, + }).returning(); + return mapSetting(rows[0] as ServiceSettingRow); } const existing = syncDb().prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? AND key = ? AND scope = ?") .get(input.serviceId, input.key, input.scope) as unknown as ServiceSettingRow | undefined; @@ -718,7 +789,7 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL async deleteSetting(id: string): Promise { if (asyncLayer) { - await asyncLayer.db.delete(cliPressSettings).where(eq(cliPressSettings.id, id)); + await asyncLayer.db.delete(cliPressSettings).where(and(eq(cliPressSettings.projectId, projectId()), eq(cliPressSettings.id, id))); return; } syncDb().prepare("DELETE FROM cli_press_service_settings WHERE id = ?").run(id); diff --git a/plugins/fusion-plugin-cli-printing-press/src/tools.ts b/plugins/fusion-plugin-cli-printing-press/src/tools.ts new file mode 100644 index 0000000000..bbaee01d23 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/tools.ts @@ -0,0 +1,161 @@ +import type { + PluginContext, + PluginRouteDefinition, + PluginRouteResponse, + PluginToolDefinition, + PluginToolResult, +} from "@fusion/plugin-sdk"; +import { createCliPrintingPressRoutes } from "./routes/wizard-routes.js"; + +/* +FNXC:CliPrintingPressAgentTools 2026-07-14-18:47: +Agents need project-scoped CRUD, generation, and test access to CLI definitions. Every tool delegates to the existing plugin routes so draft validation, PostgreSQL ownership, generated-artifact checks, credential handling, and execution limits remain identical across agent and dashboard workflows. +*/ + +function textResult(text: string, details?: Record, isError = false): PluginToolResult { + return { content: [{ type: "text", text }], details, isError }; +} + +function stringParam(params: Record, key: string): string | undefined { + const value = params[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function routeResponse(value: unknown): PluginRouteResponse { + if (!value || typeof value !== "object" || typeof (value as { status?: unknown }).status !== "number") { + return { status: 200, body: value }; + } + return value as PluginRouteResponse; +} + +async function invokeRoute( + routes: PluginRouteDefinition[], + method: PluginRouteDefinition["method"], + path: string, + request: unknown, + ctx: PluginContext, +): Promise { + const route = routes.find((candidate) => candidate.method === method && candidate.path === path); + if (!route) throw new Error(`CLI Printing Press tool route unavailable: ${method} ${path}`); + return routeResponse(await route.handler(request, ctx)); +} + +function responseResult(response: PluginRouteResponse, successText: string): PluginToolResult { + const isError = response.status >= 400; + const body = response.body; + const error = body && typeof body === "object" && "error" in body + ? String((body as { error: unknown }).error) + : `CLI Printing Press request failed with status ${response.status}`; + return textResult(isError ? error : successText, { status: response.status, body }, isError); +} + +const draftSchema = { + type: "object", + description: "A complete ServiceDraft; create/update use the same validation as the dashboard wizard.", + additionalProperties: true, +}; + +export function createCliPrintingPressTools( + routes: PluginRouteDefinition[] = createCliPrintingPressRoutes(), +): PluginToolDefinition[] { + return [ + { + name: "cli_press_list", + description: "List CLI service definitions in the current project.", + parameters: { type: "object", properties: {}, required: [] }, + execute: async (_params, ctx) => { + const response = await invokeRoute(routes, "GET", "/drafts", { params: {} }, ctx); + const count = Array.isArray(response.body) ? response.body.length : 0; + return responseResult(response, `Found ${count} CLI definition${count === 1 ? "" : "s"}.`); + }, + }, + { + name: "cli_press_get", + description: "Get one complete CLI service definition from the current project.", + parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + execute: async (params, ctx) => { + const id = stringParam(params, "id"); + if (!id) return textResult("id is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "GET", "/drafts/:id", { params: { id } }, ctx); + return responseResult(response, `Loaded CLI definition ${id}.`); + }, + }, + { + name: "cli_press_create", + description: "Create a validated CLI service definition in the current project.", + parameters: { type: "object", properties: { draft: draftSchema }, required: ["draft"] }, + execute: async (params, ctx) => { + const response = await invokeRoute(routes, "POST", "/drafts", { params: {}, body: params.draft }, ctx); + return responseResult(response, "Created CLI definition."); + }, + }, + { + name: "cli_press_update", + description: "Replace one CLI service definition after full wizard validation.", + parameters: { + type: "object", + properties: { id: { type: "string" }, draft: draftSchema }, + required: ["id", "draft"], + }, + execute: async (params, ctx) => { + const id = stringParam(params, "id"); + if (!id) return textResult("id is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "PUT", "/drafts/:id", { params: { id }, body: params.draft }, ctx); + return responseResult(response, `Updated CLI definition ${id}.`); + }, + }, + { + name: "cli_press_delete", + description: "Delete one CLI service definition and its PostgreSQL-owned dependent records.", + parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + execute: async (params, ctx) => { + const id = stringParam(params, "id"); + if (!id) return textResult("id is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "DELETE", "/drafts/:id", { params: { id } }, ctx); + return responseResult(response, `Deleted CLI definition ${id}.`); + }, + }, + { + name: "cli_press_generate", + description: "Generate or regenerate the executable artifact for a validated CLI definition.", + parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + execute: async (params, ctx) => { + const id = stringParam(params, "id"); + if (!id) return textResult("id is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "POST", "/drafts/:id/regenerate", { params: { id } }, ctx); + return responseResult(response, `Generated CLI definition ${id}.`); + }, + }, + { + name: "cli_press_test", + description: "Run one endpoint from a generated CLI definition with bounded parameters, credentials, and timeout.", + parameters: { + type: "object", + properties: { + id: { type: "string" }, + endpointId: { type: "string" }, + params: { type: "object", additionalProperties: { type: ["string", "number", "boolean"] } }, + credentials: { type: "object", additionalProperties: { type: "string" } }, + timeoutMs: { type: "number", minimum: 1, maximum: 300000 }, + }, + required: ["id", "endpointId", "params"], + }, + execute: async (params, ctx) => { + const id = stringParam(params, "id"); + if (!id) return textResult("id is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "POST", "/drafts/:id/run", { + params: { id }, + body: { + endpointId: params.endpointId, + params: params.params, + credentials: params.credentials, + timeoutMs: params.timeoutMs, + }, + }, ctx); + return responseResult(response, `Tested CLI definition ${id}.`); + }, + }, + ]; +} + +export const cliPrintingPressTools = createCliPrintingPressTools(); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts index b77fa8d7f0..9f620b3622 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts @@ -16,7 +16,7 @@ import { execSync } from "node:child_process"; import { afterAll, beforeAll, expect, it } from "vitest"; -import { sql } from "drizzle-orm"; +import { getTableColumns, sql } from "drizzle-orm"; import { applySchemaBaseline, createAsyncDataLayer, @@ -26,6 +26,11 @@ import { type ResolvedBackend, } from "@fusion/core"; import { CePipelineStore } from "../sync/pipeline-store.js"; +import { + cePipelineLinks as localCePipelineLinks, + cePipelineState as localCePipelineState, + cePipelineSyncQueue as localCePipelineSyncQueue, +} from "../sync/pg-schema.js"; import { CeSessionStore, PlanHandoffClaimError } from "../session/session-store.js"; import { PG_AVAILABLE, @@ -120,6 +125,24 @@ afterAll(async () => { } }); +it("keeps bundle-local CE Drizzle columns aligned with the canonical core schema", () => { + /* + FNXC:CompoundEngineeringSchema 2026-07-14-23:53: + This contract is deliberately outside the PostgreSQL-gated suite: schema drift must fail in every test environment even when the published-bundle shim prevents importing canonical core table objects at runtime. + */ + const columnSignature = (table: Parameters[0]) => + Object.entries(getTableColumns(table)).map(([property, column]) => ({ + property, + name: column.name, + dataType: column.dataType, + notNull: column.notNull, + })).sort((a, b) => a.name.localeCompare(b.name)); + + expect(columnSignature(localCePipelineLinks)).toEqual(columnSignature(postgresSchema.plugin.cePipelineLinks)); + expect(columnSignature(localCePipelineState)).toEqual(columnSignature(postgresSchema.plugin.cePipelineState)); + expect(columnSignature(localCePipelineSyncQueue)).toEqual(columnSignature(postgresSchema.plugin.cePipelineSyncQueue)); +}); + pgDescribe("CePipelineStore (PG backend mode)", () => { it("persists sessions and isolates identical lookups by bound project", async () => { const a = new CeSessionStore(null, ctx!.layer); @@ -220,6 +243,25 @@ pgDescribe("CePipelineStore (PG backend mode)", () => { expect(miss).toBeUndefined(); }); + it("isolates identical pipeline, task, and queue ids between two bound projects", async () => { + const projectA = new CePipelineStore(null, ctx!.layer); + const projectB = new CePipelineStore(null, ctx!.layerB); + await projectA.createLinkAsync({ id: "shared-link", taskId: "shared-task", cePipelineId: "shared-pipeline", ceStageId: "work" }); + await projectB.createLinkAsync({ id: "shared-link", taskId: "shared-task", cePipelineId: "shared-pipeline", ceStageId: "review" }); + await projectA.upsertStateAsync({ cePipelineId: "shared-pipeline", currentStage: "work" }); + await projectB.upsertStateAsync({ cePipelineId: "shared-pipeline", currentStage: "review" }); + await projectA.enqueueSyncAsync({ id: "shared-queue", cePipelineId: "shared-pipeline", taskId: "shared-task", reason: "task_moved" }); + await projectB.enqueueSyncAsync({ id: "shared-queue", cePipelineId: "shared-pipeline", taskId: "shared-task", reason: "task_completed" }); + + expect((await projectA.findByTaskIdAsync("shared-task"))?.ceStageId).toBe("work"); + expect((await projectB.findByTaskIdAsync("shared-task"))?.ceStageId).toBe("review"); + expect((await projectA.getStateAsync("shared-pipeline"))?.currentStage).toBe("work"); + expect((await projectB.getStateAsync("shared-pipeline"))?.currentStage).toBe("review"); + await projectB.markSyncProcessedAsync("shared-queue"); + expect((await projectA.listPendingSyncAsync()).some((entry) => entry.id === "shared-queue")).toBe(true); + expect((await projectB.listPendingSyncAsync()).some((entry) => entry.id === "shared-queue")).toBe(false); + }); + it("state upsert seeds then updates; listAllState sweeps all", async () => { const store = new CePipelineStore(null, ctx!.layer); const seeded = await store.upsertStateAsync({ @@ -248,6 +290,35 @@ pgDescribe("CePipelineStore (PG backend mode)", () => { expect(all.some((s) => s.cePipelineId === "pipe-state-1")).toBe(true); }); + it("atomically preserves independently omitted state fields under concurrent upserts", async () => { + const store = new CePipelineStore(null, ctx!.layer); + await store.upsertStateAsync({ + cePipelineId: "pipe-state-concurrent", + currentStage: "work", + status: "running", + lastArtifactPath: null, + }); + + await Promise.all([ + store.upsertStateAsync({ + cePipelineId: "pipe-state-concurrent", + currentStage: "review", + status: "awaiting_board", + }), + store.upsertStateAsync({ + cePipelineId: "pipe-state-concurrent", + currentStage: "review", + lastArtifactPath: "/artifacts/concurrent-review.md", + }), + ]); + + expect(await store.getStateAsync("pipe-state-concurrent")).toMatchObject({ + currentStage: "review", + status: "awaiting_board", + lastArtifactPath: "/artifacts/concurrent-review.md", + }); + }); + it("transitionStateAsync advances status and stage", async () => { const store = new CePipelineStore(null, ctx!.layer); await store.upsertStateAsync({ diff --git a/plugins/fusion-plugin-compound-engineering/src/index.ts b/plugins/fusion-plugin-compound-engineering/src/index.ts index 7479cbb4f6..729b5e04ce 100644 --- a/plugins/fusion-plugin-compound-engineering/src/index.ts +++ b/plugins/fusion-plugin-compound-engineering/src/index.ts @@ -71,8 +71,7 @@ const plugin = definePlugin({ state: "installed", skills: COMPOUND_ENGINEERING_SKILLS, hooks: { - // Idempotent DDL for the plugin-local CE tables (ce_sessions). Runs against - // the same DB route handlers reach via ctx.taskStore.getDatabase() (U5). + // Idempotent DDL for plugin-local CE tables; runtime handlers use the host PostgreSQL layer. onSchemaInit: ensureCeSchema, // INBOUND board→pipeline sync (U8 / FN-5719). The 5s hook budget // (plugin-runner invokeHookSafe) means these MUST be fast: resolve the link, diff --git a/plugins/fusion-plugin-compound-engineering/src/schema.ts b/plugins/fusion-plugin-compound-engineering/src/schema.ts index d6a8949fae..84e0f0e279 100644 --- a/plugins/fusion-plugin-compound-engineering/src/schema.ts +++ b/plugins/fusion-plugin-compound-engineering/src/schema.ts @@ -3,9 +3,8 @@ import type { Database } from "@fusion/core"; /** * Idempotent DDL for the Compound Engineering plugin-local tables (U5). * - * Wired via `hooks.onSchemaInit` and run against the same DB that route - * handlers reach through `ctx.taskStore.getDatabase()` (the sanctioned - * plugin-table access path; `PluginContext` exposes no `db` handle and the + * Wired via `hooks.onSchemaInit` and materialized in the same PostgreSQL schema + * that route handlers access through `ctx.taskStore.getAsyncLayer()`; the * loader `emitEvent` is a logging stub — see the U5 storage/event seam note). * * `ce_sessions` is the no-silent-loss core: every interactive stage session is diff --git a/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts b/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts index 85f28b4bed..0dfbd8285c 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts @@ -184,7 +184,7 @@ function rowToSession(row: CeSessionRow): CeSession { /** * Plugin-local persistence for CE interactive sessions. Reaches the DB the same - * way reports does (via `ctx.taskStore.getDatabase()`), and ensures its schema + * way reports does (via `ctx.taskStore.getAsyncLayer()`), and ensures its schema * defensively on construction so a store created before `onSchemaInit` ran (or * in a test) still works. */ @@ -540,11 +540,9 @@ export function getCeSessionStore(ctx: PluginContext): CeSessionStore { const key = ctx.taskStore as object; const cached = storeCache.get(key); if (cached) return cached; - // FNXC:RuntimeSatelliteAsync 2026-06-24-22:40: - // In backend mode, getDatabase() throws. Guard with isBackendMode() check. const layer = ctx.taskStore.getAsyncLayer(); - const db = layer ? null : ctx.taskStore.getDatabase(); - const store = new CeSessionStore(db, layer); + if (!layer) throw new Error("Compound Engineering session store requires the project PostgreSQL AsyncDataLayer"); + const store = new CeSessionStore(null, layer); storeCache.set(key, store); return store; } diff --git a/plugins/fusion-plugin-compound-engineering/src/sync/pg-schema.ts b/plugins/fusion-plugin-compound-engineering/src/sync/pg-schema.ts index a0917fd0e8..472286d58e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/sync/pg-schema.ts +++ b/plugins/fusion-plugin-compound-engineering/src/sync/pg-schema.ts @@ -9,8 +9,11 @@ tables are plugin-OWNED (created by ensureCeSchema's raw DDL / the plugin-schema-hook); defining their typed shapes here keeps the bundle self-contained. Must stay column-identical to ensureCeSchema (../schema.ts) and core's mirror in postgres/schema/plugin.ts. + +FNXC:CompoundEngineeringSchema 2026-07-14-23:53: +The published plugin bundle still cannot import core's canonical Drizzle objects because the CLI aliases @fusion/core to a deliberately minimal runtime shim. Keep these bundle-local definitions until that boundary exports schema objects, and enforce exact column-name parity through pipeline-store.pg.test.ts so either side cannot drift silently. */ -import { text, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { text, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; import { pgSchema } from "drizzle-orm/pg-core"; // Same fixed schema name core uses (postgres/schema/_shared.ts PROJECT_SCHEMA). @@ -18,32 +21,37 @@ const projectSchema = pgSchema("project"); /** ce_pipeline_links (U7) — board-task ↔ CE-pipeline/stage/artifact back-ref. */ export const cePipelineLinks = projectSchema.table("ce_pipeline_links", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), taskId: text("task_id").notNull(), cePipelineId: text("ce_pipeline_id").notNull(), ceStageId: text("ce_stage_id").notNull(), ceArtifactPath: text("ce_artifact_path"), createdAt: text("created_at").notNull(), }, (t) => [ - index("idxCePipelineLinksPipeline").on(t.cePipelineId, t.createdAt, t.id), - uniqueIndex("idxCePipelineLinksTask").on(t.taskId), + primaryKey({ columns: [t.projectId, t.id] }), + index("idxCePipelineLinksPipeline").on(t.projectId, t.cePipelineId, t.createdAt, t.id), + uniqueIndex("idxCePipelineLinksTask").on(t.projectId, t.taskId), ]); /** ce_pipeline_state (U8) — CE pipeline's OWN state machine (vs board columns). */ export const cePipelineState = projectSchema.table("ce_pipeline_state", { - cePipelineId: text("ce_pipeline_id").primaryKey(), + projectId: text("project_id").notNull(), + cePipelineId: text("ce_pipeline_id").notNull(), currentStage: text("current_stage").notNull(), status: text("status").notNull(), lastArtifactPath: text("last_artifact_path"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - index("idxCePipelineStateStatus").on(t.status, t.updatedAt, t.cePipelineId), + primaryKey({ columns: [t.projectId, t.cePipelineId] }), + index("idxCePipelineStateStatus").on(t.projectId, t.status, t.updatedAt, t.cePipelineId), ]); /** ce_pipeline_sync_queue (U8 / FN-5719) — board→pipeline sync signal queue. */ export const cePipelineSyncQueue = projectSchema.table("ce_pipeline_sync_queue", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + id: text("id").notNull(), cePipelineId: text("ce_pipeline_id").notNull(), taskId: text("task_id").notNull(), reason: text("reason").notNull(), @@ -52,6 +60,7 @@ export const cePipelineSyncQueue = projectSchema.table("ce_pipeline_sync_queue", enqueuedAt: text("enqueued_at").notNull(), processedAt: text("processed_at"), }, (t) => [ - index("idxCePipelineSyncQueuePending").on(t.processedAt, t.enqueuedAt, t.id), - index("idxCePipelineSyncQueuePipeline").on(t.cePipelineId, t.enqueuedAt, t.id), + primaryKey({ columns: [t.projectId, t.id] }), + index("idxCePipelineSyncQueuePending").on(t.projectId, t.processedAt, t.enqueuedAt, t.id), + index("idxCePipelineSyncQueuePipeline").on(t.projectId, t.cePipelineId, t.enqueuedAt, t.id), ]); diff --git a/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts b/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts index aee2e2f319..09d220b11f 100644 --- a/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts +++ b/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts @@ -203,6 +203,13 @@ export class CePipelineStore { return this.asyncLayer.db; } + /** FNXC:CePipelineProjectIsolation 2026-07-14-21:28: Pipeline links, state, and queue rows share one PostgreSQL schema, so every async read and mutation binds the owning AsyncDataLayer project. */ + private projectId(): string { + const projectId = this.asyncLayer?.projectId?.trim(); + if (!projectId) throw new Error("CePipelineStore: PostgreSQL backend requires asyncLayer.projectId"); + return projectId; + } + // ── Links (U7) ───────────────────────────────────────────────────── /** Record a task→pipeline/artifact link. */ @@ -234,6 +241,7 @@ export class CePipelineStore { createdAt: new Date().toISOString(), }; await this.dbAsync().insert(cePipelineLinksTable).values({ + projectId: this.projectId(), id: link.id, taskId: link.taskId, cePipelineId: link.cePipelineId, @@ -256,7 +264,7 @@ export class CePipelineStore { if (!this.asyncLayer) return this.listByPipeline(cePipelineId); const rows = await this.dbAsync().select() .from(cePipelineLinksTable) - .where(eq(cePipelineLinksTable.cePipelineId, cePipelineId)) + .where(and(eq(cePipelineLinksTable.projectId, this.projectId()), eq(cePipelineLinksTable.cePipelineId, cePipelineId))) .orderBy(desc(cePipelineLinksTable.createdAt), cePipelineLinksTable.id); return rows.map((r) => rowToLink(r as CePipelineLinkRow)); } @@ -273,7 +281,7 @@ export class CePipelineStore { if (!this.asyncLayer) return this.findByTaskId(taskId); const rows = await this.dbAsync().select() .from(cePipelineLinksTable) - .where(eq(cePipelineLinksTable.taskId, taskId)) + .where(and(eq(cePipelineLinksTable.projectId, this.projectId()), eq(cePipelineLinksTable.taskId, taskId))) .limit(1); return rows[0] ? rowToLink(rows[0] as CePipelineLinkRow) : undefined; } @@ -293,7 +301,7 @@ export class CePipelineStore { if (!this.asyncLayer) return this.getState(cePipelineId); const rows = await this.dbAsync().select() .from(cePipelineStateTable) - .where(eq(cePipelineStateTable.cePipelineId, cePipelineId)) + .where(and(eq(cePipelineStateTable.projectId, this.projectId()), eq(cePipelineStateTable.cePipelineId, cePipelineId))) .limit(1); return rows[0] ? rowToState(rows[0] as CePipelineStateRow) : undefined; } @@ -310,6 +318,7 @@ export class CePipelineStore { if (!this.asyncLayer) return this.listAllState(); const rows = await this.dbAsync().select() .from(cePipelineStateTable) + .where(eq(cePipelineStateTable.projectId, this.projectId())) .orderBy(desc(cePipelineStateTable.updatedAt), cePipelineStateTable.cePipelineId); return rows.map((r) => rowToState(r as CePipelineStateRow)); } @@ -342,28 +351,30 @@ export class CePipelineStore { async upsertStateAsync(input: UpsertCePipelineStateInput): Promise { if (!this.asyncLayer) return this.upsertState(input); const now = new Date().toISOString(); - const existing = await this.getStateAsync(input.cePipelineId); - const status = input.status ?? existing?.status ?? "running"; - const lastArtifactPath = - input.lastArtifactPath !== undefined ? input.lastArtifactPath : existing?.lastArtifactPath ?? null; - if (existing) { - await this.dbAsync().update(cePipelineStateTable).set({ - currentStage: input.currentStage, - status, - lastArtifactPath, - updatedAt: now, - }).where(eq(cePipelineStateTable.cePipelineId, input.cePipelineId)); - } else { - await this.dbAsync().insert(cePipelineStateTable).values({ + /* + FNXC:CompoundEngineeringConcurrency 2026-07-14-23:53: + Pipeline state writers may independently advance status or attach an artifact. A read-before-write upsert lets concurrent callers replace an omitted field with a stale snapshot, so PostgreSQL must resolve the composite-key conflict atomically and retain the stored column whenever the caller omitted it. + */ + const rows = await this.dbAsync().insert(cePipelineStateTable).values({ + projectId: this.projectId(), cePipelineId: input.cePipelineId, currentStage: input.currentStage, - status, - lastArtifactPath, + status: input.status ?? "running", + lastArtifactPath: input.lastArtifactPath ?? null, createdAt: now, updatedAt: now, - }); - } - return (await this.getStateAsync(input.cePipelineId))!; + }).onConflictDoUpdate({ + target: [cePipelineStateTable.projectId, cePipelineStateTable.cePipelineId], + set: { + currentStage: input.currentStage, + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.lastArtifactPath !== undefined + ? { lastArtifactPath: input.lastArtifactPath } + : {}), + updatedAt: now, + }, + }).returning(); + return rowToState(rows[0] as CePipelineStateRow); } /** @@ -439,6 +450,7 @@ export class CePipelineStore { processedAt: null, }; await this.dbAsync().insert(cePipelineSyncQueueTable).values({ + projectId: this.projectId(), id: entry.id, cePipelineId: entry.cePipelineId, taskId: entry.taskId, @@ -463,7 +475,7 @@ export class CePipelineStore { if (!this.asyncLayer) return this.listPendingSync(); const rows = await this.dbAsync().select() .from(cePipelineSyncQueueTable) - .where(isNull(cePipelineSyncQueueTable.processedAt)) + .where(and(eq(cePipelineSyncQueueTable.projectId, this.projectId()), isNull(cePipelineSyncQueueTable.processedAt))) .orderBy(cePipelineSyncQueueTable.enqueuedAt, cePipelineSyncQueueTable.id); return rows.map((r) => rowToQueueEntry(r as CeSyncQueueRow)); } @@ -483,6 +495,7 @@ export class CePipelineStore { await this.dbAsync().update(cePipelineSyncQueueTable) .set({ processedAt: new Date().toISOString() }) .where(and( + eq(cePipelineSyncQueueTable.projectId, this.projectId()), eq(cePipelineSyncQueueTable.id, id), isNull(cePipelineSyncQueueTable.processedAt), )); @@ -496,15 +509,10 @@ export function getCePipelineStore(ctx: PluginContext): CePipelineStore { const key = ctx.taskStore as object; const cached = storeCache.get(key); if (cached) return cached; - // FNXC:PostgresCutover 2026-07-04-00:00 RESOLVED: - // In backend mode the sync SQLite Database is unavailable (getDatabase() - // throws), but the TaskStore's AsyncDataLayer is wired through. Pass both: - // the *Async() siblings use asyncLayer in backend mode; the sync methods - // remain as the SQLite fallback for non-backend callers/tests. - const backendMode = ctx.taskStore.isBackendMode(); - const db = backendMode ? null : ctx.taskStore.getDatabase(); - const asyncLayer = backendMode ? ctx.taskStore.getAsyncLayer() : null; - const store = new CePipelineStore(db, asyncLayer); + const asyncLayer = ctx.taskStore.getAsyncLayer(); + if (!asyncLayer) throw new Error("Compound Engineering pipeline store requires the project PostgreSQL AsyncDataLayer"); + /* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Bundled CE pipeline state is PostgreSQL-only at runtime. */ + const store = new CePipelineStore(null, asyncLayer); storeCache.set(key, store); return store; } diff --git a/plugins/fusion-plugin-even-realities-glasses/package.json b/plugins/fusion-plugin-even-realities-glasses/package.json index a99db6fc56..437cb82ef8 100644 --- a/plugins/fusion-plugin-even-realities-glasses/package.json +++ b/plugins/fusion-plugin-even-realities-glasses/package.json @@ -23,7 +23,8 @@ }, "dependencies": { "@fusion/core": "workspace:*", - "@fusion/plugin-sdk": "workspace:*" + "@fusion/plugin-sdk": "workspace:*", + "drizzle-orm": "^0.45.2" }, "devDependencies": { "@types/node": "^25.5.2", diff --git a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/index.test.ts b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/index.test.ts index 7d462659ae..2afc206863 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/index.test.ts @@ -17,10 +17,9 @@ describe("even realities plugin", () => { ]); }); - it("creates notifier dedupe table on schema init", () => { - const exec = vi.fn(); - plugin.hooks?.onSchemaInit?.({ exec } as never); - expect(exec).toHaveBeenCalledWith(expect.stringContaining("CREATE TABLE IF NOT EXISTS even_realities_seen_tasks")); + it("leaves schema creation to the registered PostgreSQL startup hook", () => { + /* FNXC:EvenRealitiesPostgres 2026-07-14-17:55: Plugin runtime hooks no longer execute SQLite DDL; core's registered migration-connection hook owns PostgreSQL schema creation. */ + expect(plugin.hooks?.onSchemaInit).toBeUndefined(); }); it("returns 503 for unknown instance routes", async () => { @@ -36,10 +35,7 @@ describe("even realities plugin", () => { }); it("handles known instance route after load", async () => { - const db = { - exec: vi.fn(), - prepare: vi.fn(() => ({ all: () => [], run: vi.fn() })), - }; + const layer = { projectId: "known-project", db: {} }; const ctx = { pluginId: "known", settings: { @@ -48,7 +44,7 @@ describe("even realities plugin", () => { companionWebhookUrl: "https://companion.example", }, logger: console, - taskStore: { getPluginStore: () => ({ db }) }, + taskStore: { getAsyncLayer: () => layer, listTasks: vi.fn(async () => []) }, } as never; await plugin.hooks?.onLoad?.(ctx); diff --git a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.pg.test.ts b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.pg.test.ts new file mode 100644 index 0000000000..50594f7c2e --- /dev/null +++ b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.pg.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from "vitest"; +import type { AsyncDataLayer } from "@fusion/core"; +import { + createTaskStoreForTest, + pgDescribe, +} from "../../../../packages/core/src/__test-utils__/pg-test-harness.js"; +import { pruneMissing, readSnapshot, writeSnapshot } from "../notifications/store.js"; + +function bind(layer: AsyncDataLayer, projectId: string): AsyncDataLayer { + return { ...layer, projectId }; +} + +pgDescribe("Even Realities notification snapshots on PostgreSQL", () => { + /* FNXC:EvenRealitiesPostgres 2026-07-14-17:45: Runtime snapshot writes, reads, and pruning must use the bound project partition and permit identical task IDs in another project. */ + it("persists and prunes only the bound project's snapshot", async () => { + const h = await createTaskStoreForTest({ prefix: "fusion_even_realities" }); + try { + const projectA = bind(h.layer, "even-a"); + const projectB = bind(h.layer, "even-b"); + await expect(writeSnapshot(h.layer, [])).rejects.toThrow("requires asyncLayer.projectId"); + await writeSnapshot(projectA, [ + { taskId: "FN-1", lastColumn: "todo", updatedAt: "2026-07-14T17:00:00.000Z" }, + { taskId: "FN-2", lastColumn: "in-review", updatedAt: "2026-07-14T17:01:00.000Z" }, + ]); + await writeSnapshot(projectB, [ + { taskId: "FN-1", lastColumn: "done", updatedAt: "2026-07-14T17:02:00.000Z" }, + ]); + await writeSnapshot(projectA, [ + { taskId: "FN-1", lastColumn: "in-progress", updatedAt: "2026-07-14T17:03:00.000Z" }, + { taskId: "FN-2", lastColumn: "done", updatedAt: "2026-07-14T17:04:00.000Z" }, + ]); + expect((await readSnapshot(projectA)).get("FN-1")?.lastColumn).toBe("in-progress"); + expect((await readSnapshot(projectA)).get("FN-2")?.lastColumn).toBe("done"); + expect((await readSnapshot(projectB)).get("FN-1")?.lastColumn).toBe("done"); + expect(await pruneMissing(projectA, new Set(["FN-2"]))).toBe(1); + const remainingA = await readSnapshot(projectA); + const remainingB = await readSnapshot(projectB); + expect([...remainingA.keys()]).toEqual(["FN-2"]); + expect([...remainingB.keys()]).toEqual(["FN-1"]); + } finally { + await h.teardown(); + } + }); + + it("chunks large snapshot upserts and stale-id deletes", async () => { + const h = await createTaskStoreForTest({ prefix: "fusion_even_realities_chunks" }); + try { + const project = bind(h.layer, "even-chunks"); + const rows = Array.from({ length: 501 }, (_, index) => ({ + taskId: `FN-${index}`, + lastColumn: "todo" as const, + updatedAt: "2026-07-14T17:00:00.000Z", + })); + await writeSnapshot(project, rows); + expect((await readSnapshot(project)).size).toBe(501); + expect(await pruneMissing(project, new Set(["FN-500"]))).toBe(500); + expect([...(await readSnapshot(project)).keys()]).toEqual(["FN-500"]); + } finally { + await h.teardown(); + } + }); +}); diff --git a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.test.ts b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.test.ts index 5cbae5ef2d..e60c1855ba 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.test.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notification-store.test.ts @@ -1,71 +1,32 @@ import { describe, expect, it } from "vitest"; -import { pruneMissing, readSnapshot, writeSnapshot } from "../notifications/store.js"; +import { changedSnapshotRows, missingSnapshotIds } from "../notifications/store.js"; +import type { SnapshotRow } from "../notifications/types.js"; -function createDb() { - const table = new Map(); - return { - exec: (_sql: string) => undefined, - prepare: (sql: string) => ({ - all: () => (sql.startsWith("SELECT") ? [...table.values()] : []), - run: (...args: unknown[]) => { - if (sql.startsWith("INSERT OR REPLACE")) { - const [taskId, lastColumn, updatedAt] = args as [string, string, string]; - table.set(taskId, { taskId, lastColumn, updatedAt }); - return { changes: 1 }; - } - if (sql === "DELETE FROM even_realities_seen_tasks") { - const changes = table.size; - table.clear(); - return { changes }; - } - if (sql.startsWith("DELETE FROM even_realities_seen_tasks WHERE taskId NOT IN")) { - const ids = new Set(args as string[]); - let changes = 0; - for (const key of [...table.keys()]) { - if (!ids.has(key)) { - table.delete(key); - changes += 1; - } - } - return { changes }; - } - return { changes: 0 }; - }, - get: () => undefined, - }), - }; -} - -describe("notification store", () => { - it("reads and writes snapshot rows", () => { - const db = createDb(); - writeSnapshot(db as never, [ - { taskId: "FN-1", lastColumn: "todo", updatedAt: "2026-01-01T00:00:00.000Z" }, - { taskId: "FN-2", lastColumn: "in-review", updatedAt: "2026-01-01T00:00:01.000Z" }, - ]); - - const snapshot = readSnapshot(db as never); - expect(snapshot.size).toBe(2); - expect(snapshot.get("FN-2")?.lastColumn).toBe("in-review"); +describe("notification snapshot deltas", () => { + const row = (taskId: string, lastColumn: SnapshotRow["lastColumn"], updatedAt: string): SnapshotRow => ({ + taskId, + lastColumn, + updatedAt, }); - it("prunes missing rows and returns deleted count", () => { - const db = createDb(); - writeSnapshot(db as never, [ - { taskId: "FN-1", lastColumn: "todo", updatedAt: "2026-01-01T00:00:00.000Z" }, - { taskId: "FN-2", lastColumn: "in-review", updatedAt: "2026-01-01T00:00:01.000Z" }, + it("does not rewrite unchanged snapshot rows", () => { + /* FNXC:EvenRealitiesPostgres 2026-07-14-17:55: An unchanged notifier poll performs no snapshot upsert; only new, moved, or updated tasks are written. */ + const existing = new Map([ + ["FN-1", row("FN-1", "todo", "2026-01-01T00:00:00.000Z")], + ["FN-2", row("FN-2", "in-review", "2026-01-01T00:00:01.000Z")], ]); - - const deleted = pruneMissing(db as never, new Set(["FN-2"])); - expect(deleted).toBe(1); - expect(readSnapshot(db as never).has("FN-1")).toBe(false); + expect(changedSnapshotRows(existing, [...existing.values()])).toEqual([]); + expect(changedSnapshotRows(existing, [ + row("FN-1", "in-progress", "2026-01-01T00:00:02.000Z"), + row("FN-3", "todo", "2026-01-01T00:00:03.000Z"), + ]).map(({ taskId }) => taskId)).toEqual(["FN-1", "FN-3"]); }); - it("handles empty present set", () => { - const db = createDb(); - writeSnapshot(db as never, [{ taskId: "FN-1", lastColumn: "todo", updatedAt: "2026-01-01T00:00:00.000Z" }]); - const deleted = pruneMissing(db as never, new Set()); - expect(deleted).toBe(1); - expect(readSnapshot(db as never).size).toBe(0); + it("derives a bounded delete set from the prior snapshot", () => { + const existing = new Map([ + ["FN-1", row("FN-1", "todo", "2026-01-01T00:00:00.000Z")], + ["FN-2", row("FN-2", "todo", "2026-01-01T00:00:00.000Z")], + ]); + expect(missingSnapshotIds(existing, new Set(["FN-2", "FN-3"]))).toEqual(["FN-1"]); }); }); diff --git a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notifier.test.ts b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notifier.test.ts index f389ab0e19..4bacc4c840 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notifier.test.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/notifier.test.ts @@ -1,43 +1,34 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createNotifier } from "../notifier.js"; +import type { AsyncDataLayer } from "@fusion/core"; +import { createNotifier, type NotifierDeps } from "../notifier.js"; +import type { SnapshotRow } from "../notifications/types.js"; afterEach(() => { vi.useRealTimers(); }); -function createDb() { - const table = new Map(); - return { - exec: (_sql: string) => undefined, - prepare: (sql: string) => ({ - get: () => undefined, - all: () => (sql.startsWith("SELECT") ? [...table.values()] : []), - run: (...args: unknown[]) => { - if (sql.startsWith("INSERT OR REPLACE")) { - const [taskId, lastColumn, updatedAt] = args as [string, string, string]; - table.set(taskId, { taskId, lastColumn, updatedAt }); - return { changes: 1 }; - } - if (sql === "DELETE FROM even_realities_seen_tasks") { - const changes = table.size; - table.clear(); - return { changes }; - } - if (sql.startsWith("DELETE FROM even_realities_seen_tasks WHERE taskId NOT IN")) { - const ids = new Set(args as string[]); - let changes = 0; - for (const key of [...table.keys()]) { - if (!ids.has(key)) { - table.delete(key); - changes += 1; - } - } - return { changes }; - } - return { changes: 0 }; - }, +function createPersistence() { + const table = new Map(); + const snapshotStore: NonNullable = { + read: vi.fn(async () => new Map(table)), + write: vi.fn(async (_layer, rows) => { + for (const row of rows) table.set(row.taskId, { ...row }); }), - } as any; + prune: vi.fn(async (_layer, presentTaskIds) => { + let deleted = 0; + for (const taskId of [...table.keys()]) { + if (!presentTaskIds.has(taskId)) { + table.delete(taskId); + deleted += 1; + } + } + return deleted; + }), + }; + return { + layer: { projectId: "notifier-test" } as AsyncDataLayer, + snapshotStore, + }; } function task(id: string, column: string, updatedAt: string) { @@ -46,11 +37,11 @@ function task(id: string, column: string, updatedAt: string) { describe("createNotifier", () => { it("seeds snapshot and emits only watched new tasks", async () => { - const db = createDb(); + const persistence = createPersistence(); const transport = { pushCard: vi.fn(async () => undefined) } as any; const notifier = createNotifier({ taskStore: { listTasks: vi.fn(async () => [task("FN-1", "todo", "2026-01-01T00:00:00.000Z"), task("FN-2", "in-review", "2026-01-01T00:00:01.000Z")]) } as any, - db, + ...persistence, transport, settings: { notifyOnColumns: ["in-review"] }, pluginId: "p1", @@ -64,13 +55,13 @@ describe("createNotifier", () => { }); it("emits entered-column transition once", async () => { - const db = createDb(); + const persistence = createPersistence(); const listTasks = vi .fn() .mockResolvedValueOnce([task("FN-1", "todo", "2026-01-01T00:00:00.000Z")]) .mockResolvedValueOnce([task("FN-1", "in-review", "2026-01-01T00:00:05.000Z")]); const transport = { pushCard: vi.fn(async () => undefined) } as any; - const notifier = createNotifier({ taskStore: { listTasks } as any, db, transport, settings: { notifyOnColumns: ["in-review"] }, pluginId: "p1", logger: console as any }); + const notifier = createNotifier({ taskStore: { listTasks } as any, ...persistence, transport, settings: { notifyOnColumns: ["in-review"] }, pluginId: "p1", logger: console as any }); await notifier.pollOnce(); const events = await notifier.pollOnce(); @@ -78,12 +69,31 @@ describe("createNotifier", () => { expect(events[0]?.reason).toBe("entered-column"); }); + it("does not rewrite an unchanged snapshot on the next poll", async () => { + const persistence = createPersistence(); + const tasks = [task("FN-1", "todo", "2026-01-01T00:00:00.000Z")]; + const notifier = createNotifier({ + taskStore: { listTasks: vi.fn(async () => tasks) } as any, + ...persistence, + transport: { pushCard: vi.fn(async () => undefined) } as any, + settings: {}, + pluginId: "p1", + }); + + await notifier.pollOnce(); + await notifier.pollOnce(); + expect(persistence.snapshotStore.write).toHaveBeenCalledTimes(1); + expect(persistence.snapshotStore.write).toHaveBeenCalledWith(persistence.layer, [ + expect.objectContaining({ taskId: "FN-1" }), + ]); + }); + it("continues when push fails", async () => { - const db = createDb(); + const persistence = createPersistence(); const transport = { pushCard: vi.fn().mockRejectedValueOnce(new Error("nope")).mockResolvedValueOnce(undefined) } as any; const notifier = createNotifier({ taskStore: { listTasks: vi.fn(async () => [task("FN-1", "in-review", "2026-01-01T00:00:00.000Z"), task("FN-2", "in-review", "2026-01-01T00:00:01.000Z")]) } as any, - db, + ...persistence, transport, settings: { notifyOnColumns: ["in-review"] }, pluginId: "p1", @@ -98,7 +108,7 @@ describe("createNotifier", () => { it("stop clears timer", async () => { vi.useFakeTimers(); const listTasks = vi.fn(async () => [task("FN-1", "todo", "2026-01-01T00:00:00.000Z")]); - const notifier = createNotifier({ taskStore: { listTasks } as any, db: createDb(), transport: { pushCard: vi.fn(async () => undefined) } as any, settings: { pollingIntervalSeconds: 5 }, pluginId: "p1", logger: console as any }); + const notifier = createNotifier({ taskStore: { listTasks } as any, ...createPersistence(), transport: { pushCard: vi.fn(async () => undefined) } as any, settings: { pollingIntervalSeconds: 5 }, pluginId: "p1", logger: console as any }); notifier.start(); await vi.advanceTimersByTimeAsync(5000); const before = listTasks.mock.calls.length; @@ -108,9 +118,9 @@ describe("createNotifier", () => { }); it("peek/drain and ring buffer", async () => { - const db = createDb(); + const persistence = createPersistence(); const tasks = Array.from({ length: 220 }, (_, i) => task(`FN-${i}`, "in-review", `2026-01-01T00:00:${String(i % 60).padStart(2, "0")}.000Z`)); - const notifier = createNotifier({ taskStore: { listTasks: vi.fn(async () => tasks) } as any, db, transport: { pushCard: vi.fn(async () => undefined) } as any, settings: { notifyOnColumns: ["in-review"] }, pluginId: "p1", logger: console as any }); + const notifier = createNotifier({ taskStore: { listTasks: vi.fn(async () => tasks) } as any, ...persistence, transport: { pushCard: vi.fn(async () => undefined) } as any, settings: { notifyOnColumns: ["in-review"] }, pluginId: "p1", logger: console as any }); await notifier.pollOnce(); expect(notifier.peekPending(200)).toHaveLength(200); diff --git a/plugins/fusion-plugin-even-realities-glasses/src/index.ts b/plugins/fusion-plugin-even-realities-glasses/src/index.ts index 208e9ac35f..bf254575e8 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/index.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/index.ts @@ -1,4 +1,5 @@ import { definePlugin } from "@fusion/plugin-sdk"; +import type { AsyncDataLayer } from "@fusion/core"; import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk"; import { createNotifier } from "./notifier.js"; import { requestReview, startWork } from "./agent-actions.js"; @@ -17,25 +18,21 @@ import { import { WebhookGlassesTransport } from "./transport.js"; import { createTransportRoutes } from "./routes/transport-routes.js"; -export type PluginDb = { - exec(sql: string): void; - prepare(sql: string): { - get(...args: unknown[]): unknown; - all(...args: unknown[]): unknown; - run(...args: unknown[]): unknown; - }; -}; - type PluginInstance = { transport: WebhookGlassesTransport; notifier: ReturnType; };const instances = new Map(); -function getDbFromTaskStore(ctx: PluginContext): PluginDb { - const pluginStore = ctx.taskStore.getPluginStore(); - const db = (pluginStore as unknown as { db?: PluginDb }).db; - if (!db) throw new Error("Plugin database unavailable"); - return db; +function getPersistenceFromTaskStore(ctx: PluginContext): AsyncDataLayer { + /* + FNXC:EvenRealitiesPostgres 2026-07-14-17:55: + The glasses notifier is a PostgreSQL-only runtime. It must receive the project TaskStore's bound AsyncDataLayer and fail loudly when unavailable; private PluginStore database casts and synchronous SQLite prepare/exec fallbacks are forbidden after cutover. + */ + const layer = ctx.taskStore.getAsyncLayer(); + if (!layer?.projectId?.trim()) { + throw new Error("Even Realities plugin requires a project-bound PostgreSQL AsyncDataLayer"); + } + return layer; } function getInstanceOrResponse(ctx: PluginContext): { instance?: PluginInstance; error?: PluginRouteResponse } { @@ -91,22 +88,13 @@ const plugin: FusionPlugin = definePlugin({ state: "installed", routes: [...coreRoutes, ...boardRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes, ...transportRoutes], hooks: { - onSchemaInit: (db) => { - (db as PluginDb).exec(` - CREATE TABLE IF NOT EXISTS even_realities_seen_tasks ( - taskId TEXT PRIMARY KEY, - lastColumn TEXT NOT NULL, - updatedAt TEXT NOT NULL - ) - `); - }, onLoad: async (ctx) => { const token = getFusionToken(ctx.settings); if (!token) { ctx.logger.warn("fusionApiToken is missing; even-realities plugin not initialized"); return; } - const db = getDbFromTaskStore(ctx); + const layer = getPersistenceFromTaskStore(ctx); const transport = new WebhookGlassesTransport({ companionWebhookUrl: getCompanionWebhookUrl(ctx.settings), }); @@ -138,7 +126,7 @@ const plugin: FusionPlugin = definePlugin({ const notifier = createNotifier({ taskStore: ctx.taskStore, - db, + layer, transport, settings: ctx.settings, logger: ctx.logger, diff --git a/plugins/fusion-plugin-even-realities-glasses/src/notifications/store.ts b/plugins/fusion-plugin-even-realities-glasses/src/notifications/store.ts index ba7238d8f5..f4bf3ad53e 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/notifications/store.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/notifications/store.ts @@ -1,46 +1,102 @@ -import type { Column } from "@fusion/core"; -import type { PluginDb } from "../index.js"; +import { postgresSchema, type AsyncDataLayer, type Column } from "@fusion/core"; +import { and, eq, inArray, sql } from "drizzle-orm"; import type { SnapshotRow } from "./types.js"; -export function readSnapshot(db: PluginDb): Map { - const rows = db.prepare("SELECT taskId, lastColumn, updatedAt FROM even_realities_seen_tasks").all() as Array<{ - taskId: string; - lastColumn: Column; - updatedAt: string; - }>; - const out = new Map(); - for (const row of rows) { - out.set(row.taskId, { taskId: row.taskId, lastColumn: row.lastColumn, updatedAt: row.updatedAt }); - } - return out; +const WRITE_CHUNK_SIZE = 250; +const DELETE_CHUNK_SIZE = 250; + +function projectIdFor(layer: AsyncDataLayer): string { + const projectId = layer.projectId?.trim(); + if (!projectId) throw new Error("Even Realities PostgreSQL persistence requires asyncLayer.projectId"); + return projectId; } -export function writeSnapshot(db: PluginDb, rows: ReadonlyArray): void { - db.exec("BEGIN"); - try { - const stmt = db.prepare( - "INSERT OR REPLACE INTO even_realities_seen_tasks(taskId, lastColumn, updatedAt) VALUES (?, ?, ?)", - ); - for (const row of rows) { - stmt.run(row.taskId, row.lastColumn, row.updatedAt); +/* +FNXC:EvenRealitiesPostgres 2026-07-14-17:55: +Glasses notification dedupe state is PostgreSQL-only runtime data. Polling compares the current task snapshot with the project-scoped persisted snapshot, writes only changed rows in bounded bulk upserts, and deletes only known-stale IDs in bounded chunks; it must never rebuild every row or generate a board-sized NOT IN predicate on an unchanged poll. +*/ +export function changedSnapshotRows( + snapshot: ReadonlyMap, + rows: ReadonlyArray, +): SnapshotRow[] { + return rows.filter((row) => { + const previous = snapshot.get(row.taskId); + return !previous + || previous.lastColumn !== row.lastColumn + || previous.updatedAt !== row.updatedAt; + }); +} + +export function missingSnapshotIds( + snapshot: ReadonlyMap, + presentTaskIds: ReadonlySet, +): string[] { + return [...snapshot.keys()].filter((taskId) => !presentTaskIds.has(taskId)); +} + +export async function readSnapshot(layer: AsyncDataLayer): Promise> { + const projectId = projectIdFor(layer); + const rows = await layer.db + .select() + .from(postgresSchema.plugin.evenRealitiesSeenTasks) + .where(eq(postgresSchema.plugin.evenRealitiesSeenTasks.projectId, projectId)); + return new Map(rows.map((row) => [row.taskId, { + taskId: row.taskId, + lastColumn: row.lastColumn as Column, + updatedAt: row.updatedAt, + }])); +} + +export async function writeSnapshot(layer: AsyncDataLayer, rows: ReadonlyArray): Promise { + const projectId = projectIdFor(layer); + if (rows.length === 0) return; + await layer.transactionImmediate(async (tx) => { + for (let offset = 0; offset < rows.length; offset += WRITE_CHUNK_SIZE) { + const chunk = rows.slice(offset, offset + WRITE_CHUNK_SIZE); + await tx.insert(postgresSchema.plugin.evenRealitiesSeenTasks).values( + chunk.map((row) => ({ + projectId, + taskId: row.taskId, + lastColumn: row.lastColumn, + updatedAt: row.updatedAt, + })), + ).onConflictDoUpdate({ + target: [ + postgresSchema.plugin.evenRealitiesSeenTasks.projectId, + postgresSchema.plugin.evenRealitiesSeenTasks.taskId, + ], + set: { + lastColumn: sql`excluded.last_column`, + updatedAt: sql`excluded.updated_at`, + }, + }); } - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } + }); } -export function pruneMissing(db: PluginDb, presentTaskIds: ReadonlySet): number { - if (presentTaskIds.size === 0) { - const result = db.prepare("DELETE FROM even_realities_seen_tasks").run() as { changes?: number }; - return result.changes ?? 0; - } +export async function pruneMissing( + layer: AsyncDataLayer, + presentTaskIds: ReadonlySet, + snapshot?: ReadonlyMap, +): Promise { + const projectId = projectIdFor(layer); + const existing = snapshot ?? await readSnapshot(layer); + const staleIds = missingSnapshotIds(existing, presentTaskIds); + if (staleIds.length === 0) return 0; - const ids = [...presentTaskIds]; - const placeholders = ids.map(() => "?").join(","); - const result = db - .prepare(`DELETE FROM even_realities_seen_tasks WHERE taskId NOT IN (${placeholders})`) - .run(...ids) as { changes?: number }; - return result.changes ?? 0; + let deletedCount = 0; + await layer.transactionImmediate(async (tx) => { + for (let offset = 0; offset < staleIds.length; offset += DELETE_CHUNK_SIZE) { + const chunk = staleIds.slice(offset, offset + DELETE_CHUNK_SIZE); + const deleted = await tx + .delete(postgresSchema.plugin.evenRealitiesSeenTasks) + .where(and( + eq(postgresSchema.plugin.evenRealitiesSeenTasks.projectId, projectId), + inArray(postgresSchema.plugin.evenRealitiesSeenTasks.taskId, chunk), + )) + .returning({ taskId: postgresSchema.plugin.evenRealitiesSeenTasks.taskId }); + deletedCount += deleted.length; + } + }); + return deletedCount; } diff --git a/plugins/fusion-plugin-even-realities-glasses/src/notifier.ts b/plugins/fusion-plugin-even-realities-glasses/src/notifier.ts index ecdd06c949..28fd1c1a52 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/notifier.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/notifier.ts @@ -1,16 +1,15 @@ -import type { Task } from "@fusion/core"; +import type { AsyncDataLayer, Task } from "@fusion/core"; import type { PluginContext } from "@fusion/plugin-sdk"; import { notificationCard } from "./cards.js"; import { diffSnapshots } from "./notifications/diff.js"; -import { pruneMissing, readSnapshot, writeSnapshot } from "./notifications/store.js"; -import type { NotificationEvent } from "./notifications/types.js"; -import type { PluginDb } from "./index.js"; +import { changedSnapshotRows, pruneMissing, readSnapshot, writeSnapshot } from "./notifications/store.js"; +import type { NotificationEvent, SnapshotRow } from "./notifications/types.js"; import { getNotifyColumns, getPollingIntervalMs } from "./settings.js"; import type { GlassesTransport } from "./transport.js"; export interface NotifierDeps { taskStore: PluginContext["taskStore"]; - db: PluginDb; + layer: AsyncDataLayer; transport: GlassesTransport; settings: PluginContext["settings"]; logger?: PluginContext["logger"]; @@ -18,6 +17,15 @@ export interface NotifierDeps { now?: () => Date; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval; + snapshotStore?: { + read(layer: AsyncDataLayer): Promise>; + write(layer: AsyncDataLayer, rows: ReadonlyArray): Promise; + prune( + layer: AsyncDataLayer, + presentTaskIds: ReadonlySet, + snapshot: ReadonlyMap, + ): Promise; + }; } export interface Notifier { @@ -36,6 +44,11 @@ const PENDING_CAP = 200; export function createNotifier(deps: NotifierDeps): Notifier { const setIntervalImpl = deps.setIntervalImpl ?? setInterval; const clearIntervalImpl = deps.clearIntervalImpl ?? clearInterval; + const snapshotStore = deps.snapshotStore ?? { + read: readSnapshot, + write: writeSnapshot, + prune: pruneMissing, + }; let timer: ReturnType | undefined; let inFlight = false; @@ -62,7 +75,7 @@ export function createNotifier(deps: NotifierDeps): Notifier { inFlight = true; try { const tasks = (await deps.taskStore.listTasks({ includeArchived: false })) as Task[]; - const snapshot = readSnapshot(deps.db); + const snapshot = await snapshotStore.read(deps.layer); const notifyOnColumns = new Set(getNotifyColumns(deps.settings)); const events = diffSnapshots(snapshot, tasks, { notifyOnColumns, alsoNotifyOnDone: false }); const taskMap = new Map(tasks.map((task) => [task.id, task] as const)); @@ -78,11 +91,17 @@ export function createNotifier(deps: NotifierDeps): Notifier { } pending = bounded([...pending, ...events]); - writeSnapshot( - deps.db, - tasks.map((task) => ({ taskId: task.id, lastColumn: task.column, updatedAt: task.updatedAt })), - ); - pruneMissing(deps.db, new Set(tasks.map((task) => task.id))); + const currentRows = tasks.map((task) => ({ + taskId: task.id, + lastColumn: task.column, + updatedAt: task.updatedAt, + })); + const presentTaskIds = new Set(tasks.map((task) => task.id)); + const changedRows = changedSnapshotRows(snapshot, currentRows); + if (changedRows.length > 0) { + await snapshotStore.write(deps.layer, changedRows); + } + await snapshotStore.prune(deps.layer, presentTaskIds, snapshot); lastPollIso = nowIso(); return events; } catch (err) { diff --git a/plugins/fusion-plugin-even-realities-glasses/tsconfig.json b/plugins/fusion-plugin-even-realities-glasses/tsconfig.json index fdc529a99c..a2de636e43 100644 --- a/plugins/fusion-plugin-even-realities-glasses/tsconfig.json +++ b/plugins/fusion-plugin-even-realities-glasses/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"] } diff --git a/plugins/fusion-plugin-reports/README.md b/plugins/fusion-plugin-reports/README.md index 6dbb4ce85b..a988a55d80 100644 --- a/plugins/fusion-plugin-reports/README.md +++ b/plugins/fusion-plugin-reports/README.md @@ -107,7 +107,7 @@ Aggregation is deterministic: ## Report Archive -The plugin persists generated reports in SQLite via `ensureReportSchema(db)` and `ReportStore`. +The plugin persists generated reports in the project PostgreSQL schema through the shared `ReportStore` provider. Rows are scoped by canonical project identity. ### Schema diff --git a/plugins/fusion-plugin-reports/src/__tests__/report-store-provider.test.ts b/plugins/fusion-plugin-reports/src/__tests__/report-store-provider.test.ts new file mode 100644 index 0000000000..7d6e12d1a1 --- /dev/null +++ b/plugins/fusion-plugin-reports/src/__tests__/report-store-provider.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AsyncDataLayer, PluginContext } from "@fusion/core"; +import { getReportStore } from "../store/report-store-provider.js"; +import { ReportStore } from "../store/report-store.js"; + +function context(taskStore: object): PluginContext { + return { taskStore } as PluginContext; +} + +describe("getReportStore", () => { + it("prefers an injected store", () => { + const injected = {} as ReportStore; + const getInjected = vi.fn(() => injected); + const ctx = context({ getReportStore: getInjected }); + + expect(getReportStore(ctx)).toBe(injected); + expect(getInjected).toHaveBeenCalledOnce(); + }); + + it("caches one PostgreSQL store per TaskStore", () => { + const layerA = { projectId: "project-a" } as AsyncDataLayer; + const layerB = { projectId: "project-b" } as AsyncDataLayer; + const taskStoreA = { getAsyncLayer: () => layerA }; + const taskStoreB = { getAsyncLayer: () => layerB }; + + const firstA = getReportStore(context(taskStoreA)); + expect(getReportStore(context(taskStoreA))).toBe(firstA); + expect(getReportStore(context(taskStoreB))).not.toBe(firstA); + }); + + it("rejects a TaskStore without a PostgreSQL layer", () => { + expect(() => getReportStore(context({ getAsyncLayer: () => null }))).toThrow( + "Reports plugin requires the project PostgreSQL AsyncDataLayer", + ); + }); +}); diff --git a/plugins/fusion-plugin-reports/src/__tests__/report-store.pg.test.ts b/plugins/fusion-plugin-reports/src/__tests__/report-store.pg.test.ts index a365426875..f4fa9d9d47 100644 --- a/plugins/fusion-plugin-reports/src/__tests__/report-store.pg.test.ts +++ b/plugins/fusion-plugin-reports/src/__tests__/report-store.pg.test.ts @@ -11,10 +11,15 @@ import { createTaskStoreForTest, pgDescribe, } from "../../../../packages/core/src/__test-utils__/pg-test-harness.js"; +import type { AsyncDataLayer } from "@fusion/core"; import { ReportStore } from "../store/report-store.js"; import type { ReportStatus } from "../store/report-types.js"; import type { CombinedReview } from "../review-types.js"; +function projectLayer(layer: AsyncDataLayer, projectId = "reports-project-a"): AsyncDataLayer { + return { ...layer, projectId }; +} + pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("reports table is materialized by the schema-init hook", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_schema" }); @@ -33,7 +38,7 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("createReportAsync + getReportAsync round-trip", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_crud" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); const created = await store.createReportAsync({ cadence: "weekly", periodStart: "2026-07-01", @@ -55,10 +60,41 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { } }); + it("isolates identical report ids and mutations between two bound projects", async () => { + const h = await createTaskStoreForTest({ prefix: "fusion_reports_isolation" }); + try { + const projectA = new ReportStore(null, { asyncLayer: projectLayer(h.layer, "reports-project-a") }); + const projectB = new ReportStore(null, { asyncLayer: projectLayer(h.layer, "reports-project-b") }); + const report = await projectA.createReportAsync({ + cadence: "daily", + periodStart: "2026-07-14", + periodEnd: "2026-07-14", + title: "Project A", + }); + await h.adminDb.execute(sql` + INSERT INTO project.reports + SELECT 'reports-project-b', id, cadence, period_start, period_end, 'Project B', status, + generation_started_at, generation_completed_at, review_started_at, review_completed_at, + approved_at, approved_by, published_at, archived_at, failure_reason, approval_state, + approval_history, draft_markdown, rendered_html_path, rendered_html, + rendered_html_generated_at, metadata_json, combined_review_json, created_at, updated_at + FROM project.reports WHERE project_id='reports-project-a' AND id=${report.id} + `); + + expect((await projectA.getReportAsync(report.id))?.title).toBe("Project A"); + expect((await projectB.getReportAsync(report.id))?.title).toBe("Project B"); + await projectB.updateReportAsync(report.id, { title: "Project B updated" }); + expect((await projectA.getReportAsync(report.id))?.title).toBe("Project A"); + expect((await projectB.getReportAsync(report.id))?.title).toBe("Project B updated"); + } finally { + await h.teardown(); + } + }); + it("getReportAsync returns null for unknown id", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_miss" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); const fetched = await store.getReportAsync("rep_nonexistent"); expect(fetched).toBeNull(); } finally { @@ -69,7 +105,7 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("listReportsAsync with filters, ordering, and pagination", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_list" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); for (let i = 0; i < 5; i++) { await store.createReportAsync({ cadence: i < 3 ? "daily" : "weekly", @@ -107,7 +143,7 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("setStatusAsync validates transitions and sets timestamps", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_status" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); const created = await store.createReportAsync({ cadence: "daily", periodStart: "2026-07-01", @@ -135,7 +171,7 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("attachReviewAsync requires review_in_progress and stores combined review", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_review" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); const created = await store.createReportAsync({ cadence: "daily", periodStart: "2026-07-01", @@ -162,7 +198,7 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("updateReportAsync + setRenderedHtmlAsync update fields", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_update" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); const created = await store.createReportAsync({ cadence: "daily", periodStart: "2026-07-01", @@ -184,7 +220,7 @@ pgDescribe("ReportStore (PostgreSQL / backend mode)", () => { it("deleteReportAsync removes the report", async () => { const h = await createTaskStoreForTest({ prefix: "fusion_reports_delete" }); try { - const store = new ReportStore(null, { asyncLayer: h.layer }); + const store = new ReportStore(null, { asyncLayer: projectLayer(h.layer) }); const created = await store.createReportAsync({ cadence: "daily", periodStart: "2026-07-01", diff --git a/plugins/fusion-plugin-reports/src/__tests__/tools.test.ts b/plugins/fusion-plugin-reports/src/__tests__/tools.test.ts new file mode 100644 index 0000000000..4b9a42441a --- /dev/null +++ b/plugins/fusion-plugin-reports/src/__tests__/tools.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginContext } from "@fusion/plugin-sdk"; +import plugin from "../index.js"; +import { createReportTools } from "../tools.js"; + +function context(): PluginContext { + return { + pluginId: "fusion-plugin-reports", + taskStore: {} as PluginContext["taskStore"], + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + }; +} + +describe("report agent tools", () => { + it("registers project report primitives and prompt vocabulary", () => { + expect(plugin.tools?.map((tool) => tool.name)).toEqual([ + "reports_list", + "reports_get", + "reports_export_html", + ]); + expect(plugin.promptContributions?.enabledByDefault).toBe(true); + expect(plugin.promptContributions?.contributions.map((item) => item.surface)).toEqual([ + "executor-system", + "executor-task", + ]); + }); + + it("does not expose privileged decisions without a host-authenticated tool principal", () => { + const tools = createReportTools(); + expect(tools.some((tool) => tool.name === "reports_decide")).toBe(false); + expect(JSON.stringify(tools)).not.toContain("actorId"); + }); +}); diff --git a/plugins/fusion-plugin-reports/src/index.ts b/plugins/fusion-plugin-reports/src/index.ts index 82f84736fd..e5fb6bdfb7 100644 --- a/plugins/fusion-plugin-reports/src/index.ts +++ b/plugins/fusion-plugin-reports/src/index.ts @@ -15,7 +15,13 @@ import { settingsSchema, } from "./settings.js"; import type { ReportCadence, ReportCreateInput } from "./store/report-types.js"; -import { ReportStore } from "./store/report-store.js"; +import { getReportStore } from "./store/report-store-provider.js"; +import { reportTools } from "./tools.js"; + +/* +FNXC:ReportsAgentVocabulary 2026-07-14-18:47: +Reports are an agent-native project domain. Prompt contributions name the primitive tools and approval vocabulary so agents discover the same review and publication workflow exposed by the dashboard, without inventing direct database mutations. +*/ const plugin = definePlugin({ manifest: { @@ -26,12 +32,27 @@ const plugin = definePlugin({ author: "Fusion Team", fusionVersion: ">=0.1.0", settingsSchema, + promptSurfaces: ["executor-system", "executor-task"], }, state: "installed", hooks: { onSchemaInit: ensureReportSchema, }, routes: [...createReportListRoutes(), ...createReportExportRoutes(), ...createReportApprovalRoutes()], + tools: reportTools, + promptContributions: { + enabledByDefault: true, + contributions: [ + { + surface: "executor-system", + content: "Reports are project-scoped records with review status and approval state. Use reports_list/reports_get to inspect them and reports_export_html only after generation completes. Approval and publication remain authenticated dashboard actions.", + }, + { + surface: "executor-task", + content: "For report work, inspect the persisted report entity and return its report id, status, approval state, and any publish targets or export result.", + }, + ], + }, dashboardViews: [ { viewId: "reports", @@ -51,27 +72,6 @@ export interface RunGeneratedReportReviewInput { cwd: string; } -const reportStoreCache = new WeakMap(); - -export function getReportStore(ctx: PluginContext): ReportStore { - const key = ctx.taskStore as object; - const cached = reportStoreCache.get(key); - if (cached) return cached; - - // FNXC:PostgresCutover 2026-07-04-00:00: - // In backend mode, pass the asyncLayer so ReportStore async methods query - // PostgreSQL via Drizzle. In SQLite mode, pass the sync Database. - if (ctx.taskStore.isBackendMode()) { - const asyncLayer = ctx.taskStore.getAsyncLayer(); - const store = new ReportStore(null, { asyncLayer }); - reportStoreCache.set(key, store); - return store; - } - const store = new ReportStore(ctx.taskStore.getDatabase()); - reportStoreCache.set(key, store); - return store; -} - function toCadence(cadence: RunReviewPanelInput["reportMetadata"]["cadence"]): ReportCadence { return cadence; } @@ -137,5 +137,7 @@ export * from "./review-types.js"; export * from "./review-panel.js"; export { ensureReportSchema } from "./report-schema.js"; export { ReportStore, ReportStoreError, type ReportStoreEvents } from "./store/report-store.js"; +export { getReportStore } from "./store/report-store-provider.js"; +export { createReportTools, reportTools } from "./tools.js"; export * from "./store/report-types.js"; export * from "./render/index.js"; diff --git a/plugins/fusion-plugin-reports/src/routes/report-approval-routes.ts b/plugins/fusion-plugin-reports/src/routes/report-approval-routes.ts index 40962b8e91..2f88f55a12 100644 --- a/plugins/fusion-plugin-reports/src/routes/report-approval-routes.ts +++ b/plugins/fusion-plugin-reports/src/routes/report-approval-routes.ts @@ -2,7 +2,7 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from " import { applyDecision, type ApprovalActor, type ApprovalDecision, type ApprovalSettings } from "../approval.js"; import { getApprovalRequired, getApproverAgentIds, getAutoPublishOnApproval, getPublishTargets } from "../settings.js"; import { buildShareBlocks } from "../share-blocks.js"; -import { ReportStore } from "../store/report-store.js"; +import { getReportStore } from "../store/report-store-provider.js"; interface RouteRequest { params: Record; @@ -10,26 +10,6 @@ interface RouteRequest { headers?: Record; } -const reportStoreCache = new WeakMap(); - -function getStore(ctx: PluginContext): ReportStore { - const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore }; - if (typeof taskStoreWithReports.getReportStore === "function") return taskStoreWithReports.getReportStore(); - const key = ctx.taskStore as object; - const cached = reportStoreCache.get(key); - if (cached) return cached; - // FNXC:PostgresCutover 2026-07-04-00:00: - // In backend mode, pass asyncLayer so ReportStore async methods work. - if (ctx.taskStore.isBackendMode()) { - const store = new ReportStore(null, { asyncLayer: ctx.taskStore.getAsyncLayer() }); - reportStoreCache.set(key, store); - return store; - } - const store = new ReportStore(ctx.taskStore.getDatabase()); - reportStoreCache.set(key, store); - return store; -} - function settingsFromContext(ctx: PluginContext): ApprovalSettings { return { approvalRequired: getApprovalRequired(ctx.settings), @@ -67,7 +47,7 @@ export function createReportApprovalRoutes(): PluginRouteDefinition[] { const mutate = (action: ApprovalDecision["action"]) => async (req: unknown, ctx: PluginContext): Promise => { const request = req as RouteRequest; const reportId = request.params.id; - const store = getStore(ctx); + const store = getReportStore(ctx); const report = await store.getReportAsync(reportId); if (!report) return notFound(reportId); @@ -93,7 +73,7 @@ export function createReportApprovalRoutes(): PluginRouteDefinition[] { handler: async (req: unknown, ctx: PluginContext): Promise => { const request = req as RouteRequest; const reportId = request.params.id; - const report = await getStore(ctx).getReportAsync(reportId); + const report = await getReportStore(ctx).getReportAsync(reportId); if (!report) return notFound(reportId); if (!(report.approvalState === "approved" || report.approvalState === "published")) { return { status: 409, body: { error: "Share blocks unlock after approval" } }; diff --git a/plugins/fusion-plugin-reports/src/routes/report-export-routes.ts b/plugins/fusion-plugin-reports/src/routes/report-export-routes.ts index 2e86b705e1..633c009153 100644 --- a/plugins/fusion-plugin-reports/src/routes/report-export-routes.ts +++ b/plugins/fusion-plugin-reports/src/routes/report-export-routes.ts @@ -1,5 +1,5 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core"; -import { ReportStore } from "../store/report-store.js"; +import { getReportStore } from "../store/report-store-provider.js"; import { renderReportHtml } from "../render/html-template.js"; import { renderStandaloneReportHtml, slugifyReportFilename } from "../render/standalone-html.js"; @@ -7,28 +7,6 @@ interface RouteRequest { params: Record; } -const reportStoreCache = new WeakMap(); - -function getStore(ctx: PluginContext): ReportStore { - const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore }; - if (typeof taskStoreWithReports.getReportStore === "function") { - return taskStoreWithReports.getReportStore(); - } - const key = ctx.taskStore as object; - const cached = reportStoreCache.get(key); - if (cached) return cached; - // FNXC:PostgresCutover 2026-07-04-00:00: - // In backend mode, pass asyncLayer so ReportStore async methods work. - if (ctx.taskStore.isBackendMode()) { - const store = new ReportStore(null, { asyncLayer: ctx.taskStore.getAsyncLayer() }); - reportStoreCache.set(key, store); - return store; - } - const store = new ReportStore(ctx.taskStore.getDatabase()); - reportStoreCache.set(key, store); - return store; -} - function notFound(message: string): PluginRouteResponse { return { status: 404, body: { error: message } }; } @@ -45,7 +23,7 @@ export function createReportExportRoutes(): PluginRouteDefinition[] { handler: async (req: unknown, ctx: PluginContext): Promise => { const request = req as RouteRequest; const id = request.params.id; - const store = getStore(ctx); + const store = getReportStore(ctx); const record = await store.getReportAsync(id); if (!record) return notFound(`Report ${id} not found`); if (record.status === "generating") return conflict(`Report ${id} is not generated yet`); @@ -69,7 +47,7 @@ export function createReportExportRoutes(): PluginRouteDefinition[] { handler: async (req: unknown, ctx: PluginContext): Promise => { const request = req as RouteRequest; const id = request.params.id; - const store = getStore(ctx); + const store = getReportStore(ctx); const record = await store.getReportAsync(id); if (!record) return notFound(`Report ${id} not found`); if (record.status === "generating") return conflict(`Report ${id} is not generated yet`); diff --git a/plugins/fusion-plugin-reports/src/routes/report-list-routes.ts b/plugins/fusion-plugin-reports/src/routes/report-list-routes.ts index d5dca50490..340643dd84 100644 --- a/plugins/fusion-plugin-reports/src/routes/report-list-routes.ts +++ b/plugins/fusion-plugin-reports/src/routes/report-list-routes.ts @@ -1,29 +1,11 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core"; -import { ReportStore } from "../store/report-store.js"; +import { getReportStore } from "../store/report-store-provider.js"; interface RouteRequest { params: Record; query?: Record; } -const reportStoreCache = new WeakMap(); - -function getStore(ctx: PluginContext): ReportStore { - const key = ctx.taskStore as object; - const cached = reportStoreCache.get(key); - if (cached) return cached; - // FNXC:PostgresCutover 2026-07-04-00:00: - // In backend mode, pass asyncLayer so ReportStore async methods work. - if (ctx.taskStore.isBackendMode()) { - const store = new ReportStore(null, { asyncLayer: ctx.taskStore.getAsyncLayer() }); - reportStoreCache.set(key, store); - return store; - } - const store = new ReportStore(ctx.taskStore.getDatabase()); - reportStoreCache.set(key, store); - return store; -} - function badRequest(message: string): PluginRouteResponse { return { status: 400, body: { error: message } }; } @@ -43,7 +25,7 @@ export function createReportListRoutes(): PluginRouteDefinition[] { const q = typeof query.q === "string" && query.q.length > 0 ? query.q.toLowerCase() : undefined; const agent = typeof query.agentId === "string" && query.agentId.length > 0 ? query.agentId.toLowerCase() : undefined; - const store = getStore(ctx); + const store = getReportStore(ctx); const reports = await store.listReportsAsync({ cadence: cadence as never, status: status as never, @@ -70,7 +52,7 @@ export function createReportListRoutes(): PluginRouteDefinition[] { path: "/reports/:id", handler: async (req: unknown, ctx: PluginContext): Promise => { const request = req as RouteRequest; - const report = await getStore(ctx).getReportAsync(request.params.id); + const report = await getReportStore(ctx).getReportAsync(request.params.id); if (!report) return { status: 404, body: { error: `Report ${request.params.id} not found` } }; return { status: 200, body: { report } }; }, diff --git a/plugins/fusion-plugin-reports/src/store/report-store-provider.ts b/plugins/fusion-plugin-reports/src/store/report-store-provider.ts new file mode 100644 index 0000000000..bccfb684e6 --- /dev/null +++ b/plugins/fusion-plugin-reports/src/store/report-store-provider.ts @@ -0,0 +1,25 @@ +import type { PluginContext } from "@fusion/core"; +import { ReportStore } from "./report-store.js"; + +const reportStoreCache = new WeakMap(); + +/** + * FNXC:PostgresSatelliteCutover 2026-07-14-18:32: + * Every reports surface for one host TaskStore must share the same project-bound PostgreSQL ReportStore. Keeping the provider here prevents route modules from creating independent caches or retaining SQLite fallback construction. + */ +export function getReportStore(ctx: PluginContext): ReportStore { + const injected = ctx.taskStore as PluginContext["taskStore"] & { + getReportStore?: () => ReportStore; + }; + if (typeof injected.getReportStore === "function") return injected.getReportStore(); + + const key = ctx.taskStore as object; + const cached = reportStoreCache.get(key); + if (cached) return cached; + + const asyncLayer = ctx.taskStore.getAsyncLayer(); + if (!asyncLayer) throw new Error("Reports plugin requires the project PostgreSQL AsyncDataLayer"); + const store = new ReportStore(null, { asyncLayer }); + reportStoreCache.set(key, store); + return store; +} diff --git a/plugins/fusion-plugin-reports/src/store/report-store.ts b/plugins/fusion-plugin-reports/src/store/report-store.ts index 7469580a8a..7db51d9ad5 100644 --- a/plugins/fusion-plugin-reports/src/store/report-store.ts +++ b/plugins/fusion-plugin-reports/src/store/report-store.ts @@ -114,6 +114,13 @@ export class ReportStore extends EventEmitter { return this.db; } + /** FNXC:ReportsProjectIsolation 2026-07-14-21:28: Shared PostgreSQL plugin tables require an explicit project owner on every row and predicate; a project-agnostic layer is invalid for report runtime access. */ + private projectId(): string { + const projectId = this.asyncLayer?.projectId?.trim(); + if (!projectId) throw new Error("ReportStore: PostgreSQL backend requires asyncLayer.projectId"); + return projectId; + } + createReport(input: ReportCreateInput): Report { const now = new Date().toISOString(); const report: Report = { @@ -490,7 +497,7 @@ export class ReportStore extends EventEmitter { const rows = await this.asyncLayer!.db .select() .from(schema.plugin.reports) - .where(eq(schema.plugin.reports.id, id)); + .where(and(eq(schema.plugin.reports.projectId, this.projectId()), eq(schema.plugin.reports.id, id))); return rows[0] ? this.drizzleRowToReport(rows[0] as DrizzleReportRow) : null; } @@ -498,7 +505,7 @@ export class ReportStore extends EventEmitter { async listReportsAsync(filter: ReportListFilter = {}): Promise { if (!this.backendMode) return this.listReports(filter); const table = schema.plugin.reports; - const conditions: SQL[] = []; + const conditions: SQL[] = [eq(table.projectId, this.projectId())]; if (filter.cadence) conditions.push(eq(table.cadence, filter.cadence)); if (filter.statusIn && filter.statusIn.length > 0) { conditions.push(inArray(table.status, filter.statusIn)); @@ -517,7 +524,7 @@ export class ReportStore extends EventEmitter { .orderBy(orderFn(orderCol), orderFn(table.id)) .limit(limit) .offset(offset); - const rows = conditions.length > 0 ? await query.where(and(...conditions)) : await query; + const rows = await query.where(and(...conditions)); return rows.map((row) => this.drizzleRowToReport(row as DrizzleReportRow)); } @@ -624,7 +631,7 @@ export class ReportStore extends EventEmitter { await this.requireReportAsync(id); await this.asyncLayer!.db .delete(schema.plugin.reports) - .where(eq(schema.plugin.reports.id, id)); + .where(and(eq(schema.plugin.reports.projectId, this.projectId()), eq(schema.plugin.reports.id, id))); this.emit("report:deleted", id); } @@ -640,7 +647,7 @@ export class ReportStore extends EventEmitter { const result = await this.asyncLayer!.db .update(schema.plugin.reports) .set(this.reportToUpdateSet(report)) - .where(eq(schema.plugin.reports.id, report.id)) + .where(and(eq(schema.plugin.reports.projectId, this.projectId()), eq(schema.plugin.reports.id, report.id))) .returning(); if (result.length === 0) { throw new ReportStoreError(`Report ${report.id} not found`); @@ -650,6 +657,7 @@ export class ReportStore extends EventEmitter { /** Map a Report to a Drizzle insert-values object. */ private reportToInsertValues(report: Report): typeof schema.plugin.reports.$inferInsert { return { + projectId: this.projectId(), id: report.id, cadence: report.cadence, periodStart: report.periodStart, diff --git a/plugins/fusion-plugin-reports/src/tools.ts b/plugins/fusion-plugin-reports/src/tools.ts new file mode 100644 index 0000000000..ab05c6f74d --- /dev/null +++ b/plugins/fusion-plugin-reports/src/tools.ts @@ -0,0 +1,131 @@ +import type { + PluginContext, + PluginRouteDefinition, + PluginRouteResponse, + PluginToolDefinition, + PluginToolResult, +} from "@fusion/plugin-sdk"; +import { createReportApprovalRoutes } from "./routes/report-approval-routes.js"; +import { createReportExportRoutes } from "./routes/report-export-routes.js"; +import { createReportListRoutes } from "./routes/report-list-routes.js"; + +/* +FNXC:ReportsAgentTools 2026-07-14-18:47: +Agents need project-scoped report list, inspection, and export capabilities with exactly the same authorization rules as the dashboard API. + +FNXC:ReportsAgentTools 2026-07-14-21:28: +Plugin tool parameters are untrusted model output, and PluginContext does not expose an authenticated execution principal. Do not register approval or publication tools until the host can bind a non-forgeable agent identity; caller-supplied actor ids must never cross the privileged decision boundary. +*/ + +function textResult(text: string, details?: Record, isError = false): PluginToolResult { + return { content: [{ type: "text", text }], details, isError }; +} + +function stringParam(params: Record, key: string): string | undefined { + const value = params[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function routeResponse(value: unknown): PluginRouteResponse { + if (!value || typeof value !== "object" || typeof (value as { status?: unknown }).status !== "number") { + return { status: 200, body: value }; + } + return value as PluginRouteResponse; +} + +async function invokeRoute( + routes: PluginRouteDefinition[], + method: PluginRouteDefinition["method"], + path: string, + request: unknown, + ctx: PluginContext, +): Promise { + const route = routes.find((candidate) => candidate.method === method && candidate.path === path); + if (!route) throw new Error(`Reports tool route unavailable: ${method} ${path}`); + return routeResponse(await route.handler(request, ctx)); +} + +function responseResult(response: PluginRouteResponse, successText: string): PluginToolResult { + const isError = response.status >= 400; + const body = response.body; + const error = body && typeof body === "object" && "error" in body + ? String((body as { error: unknown }).error) + : `Reports request failed with status ${response.status}`; + return textResult(isError ? error : successText, { status: response.status, body }, isError); +} + +export function createReportTools( + routes: PluginRouteDefinition[] = [ + ...createReportListRoutes(), + ...createReportExportRoutes(), + ...createReportApprovalRoutes(), + ], +): PluginToolDefinition[] { + return [ + { + name: "reports_list", + description: "List project reports, optionally filtered by cadence, status, date range, title text, or agent id.", + parameters: { + type: "object", + properties: { + cadence: { type: "string", enum: ["daily", "weekly", "monthly", "quarterly", "manual"] }, + status: { type: "string" }, + from: { type: "string", description: "Inclusive ISO period start." }, + to: { type: "string", description: "Inclusive ISO period end." }, + query: { type: "string", description: "Case-insensitive title search." }, + agentId: { type: "string" }, + }, + required: [], + }, + execute: async (params, ctx) => { + const response = await invokeRoute(routes, "GET", "/reports", { + params: {}, + query: { + cadence: stringParam(params, "cadence"), + status: stringParam(params, "status"), + from: stringParam(params, "from"), + to: stringParam(params, "to"), + q: stringParam(params, "query"), + agentId: stringParam(params, "agentId"), + }, + }, ctx); + const count = Array.isArray((response.body as { reports?: unknown[] } | undefined)?.reports) + ? (response.body as { reports: unknown[] }).reports.length + : 0; + return responseResult(response, `Found ${count} report${count === 1 ? "" : "s"}.`); + }, + }, + { + name: "reports_get", + description: "Get one project report with its review and approval state.", + parameters: { + type: "object", + properties: { reportId: { type: "string" } }, + required: ["reportId"], + }, + execute: async (params, ctx) => { + const reportId = stringParam(params, "reportId"); + if (!reportId) return textResult("reportId is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "GET", "/reports/:id", { params: { id: reportId } }, ctx); + return responseResult(response, `Loaded report ${reportId}.`); + }, + }, + { + name: "reports_export_html", + description: "Export a generated project report as standalone HTML and return the HTML plus response metadata.", + parameters: { + type: "object", + properties: { reportId: { type: "string" } }, + required: ["reportId"], + }, + execute: async (params, ctx) => { + const reportId = stringParam(params, "reportId"); + if (!reportId) return textResult("reportId is required.", { code: "validation_error" }, true); + const response = await invokeRoute(routes, "GET", "/reports/:id/export.html", { params: { id: reportId } }, ctx); + return responseResult(response, `Exported report ${reportId} as HTML.`); + }, + }, + ]; +} + +export const reportTools = createReportTools(); diff --git a/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts b/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts index 3f48a75a38..49a2754b3e 100644 --- a/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts +++ b/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts @@ -543,17 +543,15 @@ pgDescribe("AsyncRoadmapStore", () => { const h = await createTaskStoreForTest({ prefix: "roadmap_upgrade_single" }); try { await h.adminDb.execute(sql.raw(` - ALTER TABLE project.roadmap_features ALTER COLUMN project_id DROP NOT NULL; - ALTER TABLE project.roadmap_milestones ALTER COLUMN project_id DROP NOT NULL; - ALTER TABLE project.roadmaps ALTER COLUMN project_id DROP NOT NULL; + /* FNXC:RoadmapPostgresUpgrade 2026-07-14-21:04: Project ownership now participates in primary and foreign keys, so legacy-unowned fixtures use the supported empty owner sentinel instead of invalidating current constraints to insert NULL. */ INSERT INTO central.projects(id, name, path, created_at, updated_at) VALUES ('project-only', 'Only', '/only', '2026-07-13', '2026-07-13'); INSERT INTO project.roadmaps(id, project_id, title, created_at, updated_at) - VALUES ('RM-OLD', NULL, 'Old', '2026-07-13', '2026-07-13'); + VALUES ('RM-OLD', '', 'Old', '2026-07-13', '2026-07-13'); INSERT INTO project.roadmap_milestones(id, project_id, roadmap_id, title, order_index, created_at, updated_at) - VALUES ('RMS-OLD', NULL, 'RM-OLD', 'Old milestone', 0, '2026-07-13', '2026-07-13'); + VALUES ('RMS-OLD', '', 'RM-OLD', 'Old milestone', 0, '2026-07-13', '2026-07-13'); INSERT INTO project.roadmap_features(id, project_id, milestone_id, title, order_index, created_at, updated_at) - VALUES ('RF-OLD', NULL, 'RMS-OLD', 'Old feature', 0, '2026-07-13', '2026-07-13'); + VALUES ('RF-OLD', '', 'RMS-OLD', 'Old feature', 0, '2026-07-13', '2026-07-13'); `)); await roadmapPluginSchemaInit.init(h.adminDb); @@ -583,12 +581,11 @@ pgDescribe("AsyncRoadmapStore", () => { const h = await createTaskStoreForTest({ prefix: "roadmap_upgrade_ambiguous" }); try { await h.adminDb.execute(sql.raw(` - ALTER TABLE project.roadmaps ALTER COLUMN project_id DROP NOT NULL; INSERT INTO central.projects(id, name, path, created_at, updated_at) VALUES ('project-a', 'A', '/a', '2026-07-13', '2026-07-13'), ('project-b', 'B', '/b', '2026-07-13', '2026-07-13'); INSERT INTO project.roadmaps(id, project_id, title, created_at, updated_at) - VALUES ('RM-AMBIGUOUS', NULL, 'Ambiguous', '2026-07-13', '2026-07-13'); + VALUES ('RM-AMBIGUOUS', '', 'Ambiguous', '2026-07-13', '2026-07-13'); `)); let failure: unknown; diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts index f984ef40a1..3d2fcec47b 100644 --- a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts @@ -48,10 +48,10 @@ async function getRoadmapStore(req: RouteRequest, ctx: PluginContext): Promise(); - const keys = new Map(); - const makeKey = (category: string, id: string) => `${category}:${id}`; - let transactionSnapshot: { creds: Map; keys: Map } | null = null; +function createInMemoryPersistence() { + let credentials: string | null = null; + let keys = new Map(); let failKeyId: string | null = null; - let failAuthKeysClear = false; + let failAuthClear = false; + const key = (category: string, id: string) => `${category}:${id}`; + + const persistence: WhatsAppPersistence = { + async loadHistory() { return []; }, + async appendHistory() {}, + async wasProcessed() { return false; }, + async markProcessed() {}, + async claimMessage() { return true; }, + async loadCredentials() { return credentials; }, + async saveCredentials(value) { credentials = value; }, + async loadAuthKeys(category, ids) { + return Object.fromEntries(ids.flatMap((id) => { + const value = keys.get(key(category, id)); + return value === undefined ? [] : [[id, value]]; + })); + }, + async writeAuthKeys(batch) { + const next = new Map(keys); + for (const [category, values] of Object.entries(batch)) { + for (const [id, value] of Object.entries(values)) { + if (id === failKeyId) throw new Error("injected auth-key write failure"); + if (value === null) next.delete(key(category, id)); + else next.set(key(category, id), value); + } + } + keys = next; + }, + async clearAuthState() { + if (failAuthClear) throw new Error("injected auth clear failure"); + credentials = null; + keys.clear(); + }, + }; return { - prepare(sql: string) { - return { - get: (...args: unknown[]) => { - if (sql.includes("FROM whatsapp_auth_creds")) { - const value = creds.get("creds"); - return value ? { value } : undefined; - } - if (sql.includes("FROM whatsapp_auth_keys")) { - const key = makeKey(args[0] as string, args[1] as string); - const value = keys.get(key); - return value ? { value } : undefined; - } - return undefined; - }, - run: (...args: unknown[]) => { - if (sql.includes("INSERT INTO whatsapp_auth_creds")) { - creds.set("creds", args[0] as string); - } - if (sql.includes("DELETE FROM whatsapp_auth_creds")) { - creds.clear(); - } - if (sql.includes("INSERT INTO whatsapp_auth_keys")) { - if (args[1] === failKeyId) throw new Error("injected auth-key write failure"); - keys.set(makeKey(args[0] as string, args[1] as string), args[2] as string); - } - if (sql.includes("DELETE FROM whatsapp_auth_keys WHERE category")) { - keys.delete(makeKey(args[0] as string, args[1] as string)); - } - if (sql.includes("DELETE FROM whatsapp_auth_keys")) { - if (failAuthKeysClear) throw new Error("injected auth-key clear failure"); - keys.clear(); - } - }, - }; - }, - exec(sql: string) { - if (sql === "BEGIN IMMEDIATE") { - transactionSnapshot = { creds: new Map(creds), keys: new Map(keys) }; - } - if (sql === "COMMIT") transactionSnapshot = null; - if (sql === "ROLLBACK" && transactionSnapshot) { - creds.clear(); - for (const [key, value] of transactionSnapshot.creds) creds.set(key, value); - keys.clear(); - for (const [key, value] of transactionSnapshot.keys) keys.set(key, value); - transactionSnapshot = null; - } - }, - _creds: creds, - _keys: keys, - failAuthKeyWrite(id: string | null) { - failKeyId = id; - }, - failAuthKeyClear(value: boolean) { - failAuthKeysClear = value; - }, + persistence, + failAuthKeyWrite(id: string | null) { failKeyId = id; }, + failClear(value: boolean) { failAuthClear = value; }, + setRawKey(category: string, id: string, value: string) { keys.set(key(category, id), value); }, }; } describe("auth-state", () => { it("round-trips creds", async () => { - const db = createInMemoryDb(); - const auth = await createPluginDbAuthState(db as any); + const memory = createInMemoryPersistence(); + const auth = await createPersistenceAuthState(memory.persistence); auth.state.creds.me = { id: "123@s.whatsapp.net", name: "Fusion" } as any; await auth.saveCreds(); - - const next = await createPluginDbAuthState(db as any); + const next = await createPersistenceAuthState(memory.persistence); expect(next.state.creds.me?.id).toBe("123@s.whatsapp.net"); }); it("sets, gets, and deletes key categories", async () => { - const db = createInMemoryDb(); - const auth = await createPluginDbAuthState(db as any); - + const memory = createInMemoryPersistence(); + const auth = await createPersistenceAuthState(memory.persistence); await auth.state.keys.set({ session: { alpha: { foo: "bar" } as any }, "sender-key": { beta: { baz: "qux" } as any }, }); - - const loaded = await auth.state.keys.get("session", ["alpha", "missing"]); - expect((loaded as any).alpha.foo).toBe("bar"); - expect((loaded as any).missing).toBeUndefined(); - + expect(((await auth.state.keys.get("session", ["alpha"])) as any).alpha.foo).toBe("bar"); await auth.state.keys.set({ session: { alpha: null } }); - const removed = await auth.state.keys.get("session", ["alpha"]); - expect((removed as any).alpha).toBeUndefined(); + expect(((await auth.state.keys.get("session", ["alpha"])) as any).alpha).toBeUndefined(); }); - it("rolls back every SQLite auth-key category when a later category write fails", async () => { - const db = createInMemoryDb(); - const auth = await createPluginDbAuthState(db as any); + it("does not expose partial auth-key batches when persistence rejects", async () => { + const memory = createInMemoryPersistence(); + const auth = await createPersistenceAuthState(memory.persistence); await auth.state.keys.set({ session: { alpha: { version: "old" } as any } }); - db.failAuthKeyWrite("beta"); - + memory.failAuthKeyWrite("beta"); await expect(auth.state.keys.set({ session: { alpha: { version: "new" } as any }, - "sender-key": { - beta: { version: "new" } as any, - }, + "sender-key": { beta: { version: "new" } as any }, })).rejects.toThrow("injected auth-key write failure"); - - db.failAuthKeyWrite(null); const session = await auth.state.keys.get("session", ["alpha"]); - const senderKey = await auth.state.keys.get("sender-key", ["beta"]); expect((session as any).alpha).toEqual({ version: "old" }); - expect((senderKey as any).beta).toBeUndefined(); }); - it("clears auth state", async () => { - const db = createInMemoryDb(); - const auth = await createPluginDbAuthState(db as any); + it("clears auth state through the async persistence contract", async () => { + const memory = createInMemoryPersistence(); + const auth = await createPersistenceAuthState(memory.persistence); auth.state.creds.me = { id: "123@s.whatsapp.net", name: "Fusion" } as any; await auth.saveCreds(); await auth.state.keys.set({ session: { alpha: { ok: true } as any } }); - - await clearAuthState(db as any); - - expect(db._creds.size).toBe(0); - expect(db._keys.size).toBe(0); + await memory.persistence.clearAuthState(); + const cleared = await createPersistenceAuthState(memory.persistence); + expect(cleared.state.creds.me).toBeUndefined(); + expect((await cleared.state.keys.get("session", ["alpha"]) as any).alpha).toBeUndefined(); }); - it("rolls back credentials and keys when the second SQLite auth clear fails", async () => { - const db = createInMemoryDb(); - const auth = await createPluginDbAuthState(db as any); + it("preserves auth state when an atomic clear rejects", async () => { + const memory = createInMemoryPersistence(); + const auth = await createPersistenceAuthState(memory.persistence); auth.state.creds.me = { id: "123@s.whatsapp.net", name: "Fusion" } as any; await auth.saveCreds(); - await auth.state.keys.set({ session: { alpha: { ok: true } as any } }); - db.failAuthKeyClear(true); - - await expect(clearAuthState(db as any)).rejects.toThrow("injected auth-key clear failure"); - - db.failAuthKeyClear(false); - const restored = await createPluginDbAuthState(db as any); - expect(restored.state.creds.me?.id).toBe("123@s.whatsapp.net"); - expect((await restored.state.keys.get("session", ["alpha"]) as any).alpha).toEqual({ ok: true }); + memory.failClear(true); + await expect(memory.persistence.clearAuthState()).rejects.toThrow("injected auth clear failure"); + memory.failClear(false); + expect((await createPersistenceAuthState(memory.persistence)).state.creds.me?.id).toBe("123@s.whatsapp.net"); }); it("handles corrupt json gracefully", async () => { - const db = createInMemoryDb(); - db._keys.set("session:bad", "not-json"); - - const auth = await createPluginDbAuthState(db as any); - const loaded = await auth.state.keys.get("session", ["bad"]); - expect((loaded as any).bad).toBeUndefined(); + const memory = createInMemoryPersistence(); + memory.setRawKey("session", "bad", "not-json"); + const auth = await createPersistenceAuthState(memory.persistence); + expect(((await auth.state.keys.get("session", ["bad"])) as any).bad).toBeUndefined(); }); }); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts index 7f4fe12064..aee2c06aae 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts @@ -34,7 +34,7 @@ vi.mock("qrcode", () => ({ })); import { WhatsAppConnection } from "../connection.js"; -import { createSqliteWhatsAppPersistence } from "../persistence.js"; +import type { ChatTurn, WhatsAppPersistence } from "../persistence.js"; function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -46,42 +46,42 @@ function deferred() { return { promise, resolve, reject }; } -function createInMemoryDb() { - const sessions = new Map(); +function createInMemoryPersistence(): WhatsAppPersistence { + const sessions = new Map(); const dedupe = new Set(); - const creds = new Map(); + let credentials: string | null = null; const keys = new Map(); return { - exec() {}, - prepare(sql: string) { - return { - get: (...args: unknown[]) => { - if (sql.includes("FROM whatsapp_chat_sessions")) { - const history = sessions.get(args[0] as string); - return history ? { history } : undefined; - } - if (sql.includes("FROM whatsapp_chat_dedupe")) return dedupe.has(args[0] as string) ? { found: 1 } : undefined; - if (sql.includes("FROM whatsapp_auth_creds")) return creds.get("creds") ? { value: creds.get("creds") } : undefined; - if (sql.includes("FROM whatsapp_auth_keys")) return keys.get(`${args[0]}:${args[1]}`) ? { value: keys.get(`${args[0]}:${args[1]}`) } : undefined; - return undefined; - }, - run: (...args: unknown[]) => { - if (sql.includes("whatsapp_chat_sessions")) sessions.set(args[0] as string, args[1] as string); - if (sql.includes("DELETE FROM whatsapp_chat_dedupe")) return { changes: 0 }; - if (sql.includes("INSERT OR IGNORE INTO whatsapp_chat_dedupe")) { - const messageId = args[0] as string; - if (dedupe.has(messageId)) return { changes: 0 }; - dedupe.add(messageId); - return { changes: 1 }; - } - if (sql.includes("whatsapp_chat_dedupe")) dedupe.add(args[0] as string); - if (sql.includes("INSERT INTO whatsapp_auth_creds")) creds.set("creds", args[0] as string); - if (sql.includes("DELETE FROM whatsapp_auth_creds")) creds.clear(); - if (sql.includes("INSERT INTO whatsapp_auth_keys")) keys.set(`${args[0]}:${args[1]}`, args[2] as string); - if (sql.includes("DELETE FROM whatsapp_auth_keys WHERE category")) keys.delete(`${args[0]}:${args[1]}`); - if (sql.includes("DELETE FROM whatsapp_auth_keys")) keys.clear(); - }, - }; + async loadHistory(sender) { return [...(sessions.get(sender) ?? [])]; }, + async appendHistory(sender, turns, turnLimit) { + sessions.set(sender, [...(sessions.get(sender) ?? []), ...turns].slice(-turnLimit)); + }, + async wasProcessed(messageId) { return dedupe.has(messageId); }, + async markProcessed(messageId) { dedupe.add(messageId); }, + async claimMessage(messageId) { + if (dedupe.has(messageId)) return false; + dedupe.add(messageId); + return true; + }, + async loadCredentials() { return credentials; }, + async saveCredentials(value) { credentials = value; }, + async loadAuthKeys(category, ids) { + return Object.fromEntries(ids.flatMap((id) => { + const value = keys.get(`${category}:${id}`); + return value === undefined ? [] : [[id, value]]; + })); + }, + async writeAuthKeys(batch) { + for (const [category, values] of Object.entries(batch)) { + for (const [id, value] of Object.entries(values)) { + if (value === null) keys.delete(`${category}:${id}`); + else keys.set(`${category}:${id}`, value); + } + } + }, + async clearAuthState() { + credentials = null; + keys.clear(); }, }; } @@ -90,7 +90,7 @@ function makeCtx(settings: Record = {}) { return { pluginId: "fusion-plugin-whatsapp-chat", settings: { allowedSenders: ["15550001111"], ...settings }, - taskStore: { getRootDir: () => "/tmp", getPluginStore: () => ({}) }, + taskStore: { getRootDir: () => "/tmp" }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, emitEvent: vi.fn(), } as any; @@ -108,7 +108,7 @@ describe("WhatsAppConnection", () => { }); it("starts and stops idempotently", async () => { - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence()); await connection.start(); await connection.stop(); await connection.stop(); @@ -117,7 +117,7 @@ describe("WhatsAppConnection", () => { }); it("exposes qr updates", async () => { - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence()); await connection.start(); await mockState.handlers.get("connection.update")?.({ qr: "abc" }); expect(connection.getStatus()).toMatchObject({ state: "awaiting-qr", qr: "abc" }); @@ -125,7 +125,7 @@ describe("WhatsAppConnection", () => { it("logs rejected async EventEmitter listeners", async () => { const ctx = makeCtx(); - const connection = new WhatsAppConnection(ctx, "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(ctx, "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence()); await connection.start(); mockState.toDataURL.mockRejectedValueOnce(new Error("bad qr")); @@ -137,7 +137,7 @@ describe("WhatsAppConnection", () => { it("reconnects on close unless logged out", async () => { vi.useFakeTimers(); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence()); await connection.start(); await mockState.handlers.get("connection.update")?.({ connection: "close", lastDisconnect: { error: new Error("boom") } }); vi.advanceTimersByTime(1000); @@ -151,7 +151,7 @@ describe("WhatsAppConnection", () => { it("drops unsupported inbound traffic", async () => { const reply = vi.fn().mockResolvedValue("hello"); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createInMemoryPersistence()); await connection.start(); const upsert = mockState.handlers.get("messages.upsert")!; await upsert({ type: "notify", messages: [{ key: { remoteJid: "abc@g.us", id: "1", fromMe: false }, message: { conversation: "hi" } }] }); @@ -162,7 +162,7 @@ describe("WhatsAppConnection", () => { it("dedupes and handles reply failure with fallback", async () => { const reply = vi.fn().mockRejectedValue(new Error("nope")); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createInMemoryPersistence()); await connection.start(); const payload = { type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "m-1", fromMe: false }, message: { conversation: "hi" } }] }; await mockState.handlers.get("messages.upsert")?.(payload); @@ -173,7 +173,7 @@ describe("WhatsAppConnection", () => { it("atomically claims concurrent duplicate deliveries", async () => { const reply = vi.fn().mockResolvedValue("one reply"); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createInMemoryPersistence()); await connection.start(); const payload = { type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "same-id", fromMe: false }, message: { conversation: "hi" } }] }; @@ -189,8 +189,7 @@ describe("WhatsAppConnection", () => { it("serializes concurrent messages from one sender and preserves both turns", async () => { const firstReplyStarted = deferred(); const releaseFirstReply = deferred(); - const db = createInMemoryDb(); - const persistence = createSqliteWhatsAppPersistence(db as any); + const persistence = createInMemoryPersistence(); const reply = vi.fn(async (_ctx: unknown, _sender: string, text: string, history: Array<{ text: string }>) => { if (text === "first") { firstReplyStarted.resolve(); @@ -223,7 +222,7 @@ describe("WhatsAppConnection", () => { it("sends an accepted reply before a concurrent stop closes its socket", async () => { const replyPersisted = deferred(); const releasePersistedReply = deferred(); - const persistence = createSqliteWhatsAppPersistence(createInMemoryDb() as any); + const persistence = createInMemoryPersistence(); const appendHistory = persistence.appendHistory.bind(persistence); vi.spyOn(persistence, "appendHistory").mockImplementation(async (...args) => { await appendHistory(...args); @@ -256,7 +255,7 @@ describe("WhatsAppConnection", () => { logoutStarted.resolve(); await releaseLogout.promise; }); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence()); await connection.start(); const logout = connection.logout(); @@ -277,7 +276,7 @@ describe("WhatsAppConnection", () => { ])("drains an accepted credential save before %s clears auth", async (_surface, triggerReset) => { const saveStarted = deferred(); const releaseSave = deferred(); - const persistence = createSqliteWhatsAppPersistence(createInMemoryDb() as any); + const persistence = createInMemoryPersistence(); const saveCredentials = persistence.saveCredentials.bind(persistence); vi.spyOn(persistence, "saveCredentials").mockImplementation(async (value) => { saveStarted.resolve(); @@ -302,7 +301,7 @@ describe("WhatsAppConnection", () => { }); it("deduplicates explicit and connection-event auth resets for one socket", async () => { - const persistence = createSqliteWhatsAppPersistence(createInMemoryDb() as any); + const persistence = createInMemoryPersistence(); const clearAuthState = vi.spyOn(persistence, "clearAuthState"); const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), persistence); await connection.start(); @@ -321,7 +320,7 @@ describe("WhatsAppConnection", () => { it("logs reconnect timer rejection instead of leaking it", async () => { vi.useFakeTimers(); const ctx = makeCtx(); - const connection = new WhatsAppConnection(ctx, "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + const connection = new WhatsAppConnection(ctx, "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence()); await connection.start(); mockState.makeWASocket.mockImplementationOnce(() => { throw new Error("reconnect exploded"); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts index 77d60d1803..6fb1e90723 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts @@ -31,59 +31,26 @@ vi.mock("../connection.js", () => { return { WhatsAppConnection: ctor }; }); -import plugin, { ensureSchema, getDedupeRetentionDays, markProcessed, splitMessageForWhatsapp, wasProcessed } from "../index.js"; +import plugin, { getDedupeRetentionDays, splitMessageForWhatsapp } from "../index.js"; import { WhatsAppConnection } from "../connection.js"; - -function createInMemoryDb() { - const dedupe = new Map(); - - return { - exec(_sql: string) {}, - prepare(sql: string) { - return { - get: (...args: unknown[]) => { - if (sql.includes("FROM whatsapp_chat_dedupe") && sql.includes("messageId = ?")) { - const row = dedupe.get(args[0] as string); - return row ? { found: 1, ...row } : undefined; - } - return undefined; - }, - run: (...args: unknown[]) => { - if (sql.includes("INSERT OR IGNORE INTO whatsapp_chat_dedupe")) { - if (dedupe.has(args[0] as string)) return { changes: 0 }; - dedupe.set(args[0] as string, { - sender: args[1] as string, - receivedAt: args[2] as string, - }); - return { changes: 1 }; - } - if (sql.includes("INSERT INTO whatsapp_chat_dedupe")) { - dedupe.set(args[0] as string, { - sender: args[1] as string, - receivedAt: args[2] as string, - }); - return { changes: 1 }; - } - if (sql.includes("DELETE FROM whatsapp_chat_dedupe WHERE receivedAt < ?")) { - const cutoff = args[0] as string; - for (const [id, row] of dedupe.entries()) { - if (row.receivedAt < cutoff) dedupe.delete(id); - } - } - }, - }; - }, - _dedupe: dedupe, - }; -} +import { createWhatsAppPersistence } from "../persistence.js"; describe("whatsapp plugin", () => { beforeEach(() => { connectionInstances.length = 0; vi.clearAllMocks(); }); - it("registers schema init hook", () => { - expect(plugin.hooks?.onSchemaInit).toBeDefined(); + it("leaves schema creation to the registered PostgreSQL startup hook", () => { + /* FNXC:WhatsAppPostgresPersistence 2026-07-14-18:05: Runtime hooks no longer expose SQLite DDL; the core migration connection owns the registered WhatsApp PostgreSQL schema hook. */ + expect(plugin.hooks?.onSchemaInit).toBeUndefined(); + }); + + it("fails closed without a project-bound AsyncDataLayer", () => { + const ctx = (layer: unknown) => ({ + taskStore: { getAsyncLayer: () => layer }, + }) as unknown as PluginContext; + expect(() => createWhatsAppPersistence(ctx(null))).toThrow("requires a PostgreSQL AsyncDataLayer"); + expect(() => createWhatsAppPersistence(ctx({ projectId: "" }))).toThrow("project-bound"); }); it("registers pairing routes", () => { @@ -115,7 +82,6 @@ describe("whatsapp plugin", () => { describe("multi-project isolation", () => { it("keeps project contexts isolated with shared plugin id", async () => { - const db = createInMemoryDb(); const makeCtx = (rootDir: string): PluginContext => ({ pluginId: "fusion-plugin-whatsapp-chat", settings: {}, @@ -128,9 +94,7 @@ describe("multi-project isolation", () => { emitEvent: vi.fn(), taskStore: { getRootDir: () => rootDir, - getPluginStore: () => ({ - db, - }), + getAsyncLayer: () => ({ projectId: rootDir }), } as unknown as PluginContext["taskStore"], }); @@ -173,48 +137,7 @@ describe("multi-project isolation", () => { }); }); -describe("markProcessed retention", () => { - it("prunes rows older than retention and keeps recent rows", () => { - const db = createInMemoryDb(); - ensureSchema(db as any); - const now = Date.now(); - - db.prepare("INSERT INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)").run( - "old-id", - "sender", - new Date(now - 30 * 86_400_000).toISOString(), - ); - db.prepare("INSERT INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)").run( - "recent-id", - "sender", - new Date(now - 3_600_000).toISOString(), - ); - - markProcessed(db as any, "new-id", "sender", 7); - - const oldRow = db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get("old-id") as { found?: number } | undefined; - const recentRow = db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get("recent-id") as { found?: number } | undefined; - expect(Boolean(oldRow?.found)).toBe(false); - expect(Boolean(recentRow?.found)).toBe(true); - expect(wasProcessed(db as any, "new-id")).toBe(true); - }); - - it("keeps entries inside retention window", () => { - const db = createInMemoryDb(); - ensureSchema(db as any); - - db.prepare("INSERT INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)").run( - "one-day-old-id", - "sender", - new Date(Date.now() - 86_400_000).toISOString(), - ); - - markProcessed(db as any, "new-id", "sender", 7); - - const oneDayOld = db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get("one-day-old-id") as { found?: number } | undefined; - expect(Boolean(oneDayOld?.found)).toBe(true); - }); - +describe("dedupe retention settings", () => { it("parses dedupeRetentionDays safely", () => { expect(getDedupeRetentionDays({})).toBe(7); expect(getDedupeRetentionDays({ dedupeRetentionDays: undefined })).toBe(7); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts index 250bcadf4a..91dd1c9650 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts @@ -5,6 +5,7 @@ import { expect, it, vi } from "vitest"; import type { AsyncDataLayer } from "@fusion/core"; import type { PluginContext } from "@fusion/plugin-sdk"; +import { sql } from "drizzle-orm"; import { createTaskStoreForTest, pgDescribe, @@ -94,4 +95,26 @@ pgDescribe("WhatsAppPersistence PostgreSQL", () => { await h.teardown(); } }); + + it("prunes only expired dedupe rows inside the bound project", async () => { + const h = await createTaskStoreForTest({ prefix: "whatsapp_retention" }); + try { + const persistence = createWhatsAppPersistence(context(bind(h.layer, "project-a"))); + const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); + const recent = new Date(Date.now() - 3_600_000).toISOString(); + await h.adminDb.execute(sql`INSERT INTO project.whatsapp_chat_dedupe(project_id, message_id, sender, received_at) + VALUES ('project-a', 'old-id', 'sender', ${old}), ('project-a', 'recent-id', 'sender', ${recent}), + ('project-b', 'old-id', 'sender', ${old})`); + + await persistence.markProcessed("new-id", "sender", 7); + + expect(await persistence.wasProcessed("old-id")).toBe(false); + expect(await persistence.wasProcessed("recent-id")).toBe(true); + expect(await persistence.wasProcessed("new-id")).toBe(true); + const otherProject = createWhatsAppPersistence(context(bind(h.layer, "project-b"))); + expect(await otherProject.wasProcessed("old-id")).toBe(true); + } finally { + await h.teardown(); + } + }); }); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts b/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts index dea7035b6d..a90969758c 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts @@ -1,13 +1,11 @@ import { BufferJSON, initAuthCreds, type AuthenticationState, type AuthenticationCreds, type SignalDataSet, type SignalDataTypeMap } from "@whiskeysockets/baileys"; -import { createSqliteWhatsAppPersistence, type PluginDb, type WhatsAppPersistence } from "./persistence.js"; +import type { WhatsAppPersistence } from "./persistence.js"; type AuthStateResult = { state: AuthenticationState; saveCreds: () => Promise; }; -type AuthRow = { value: string }; - function parseStoredValue(value: string): T | null { try { return JSON.parse(value, BufferJSON.reviver) as T; @@ -20,17 +18,9 @@ function serialize(value: unknown): string { return JSON.stringify(value, BufferJSON.replacer); } -export async function clearAuthState(db: PluginDb): Promise { - await createSqliteWhatsAppPersistence(db).clearAuthState(); -} - -export async function createPluginDbAuthState(db: PluginDb): Promise { - return createPersistenceAuthState(createSqliteWhatsAppPersistence(db)); -} - /** - * FNXC:WhatsAppPostgresPersistence 2026-07-13-22:37: - * Baileys auth callbacks are already asynchronous, so the runtime auth state uses the backend-neutral persistence contract. The legacy PluginDb helper remains for SQLite compatibility tests and older plugin hosts. + * FNXC:WhatsAppPostgresPersistence 2026-07-14-18:05: + * Baileys auth callbacks use the asynchronous persistence contract supplied by the project-bound PostgreSQL layer. No auth helper accepts a synchronous plugin database, so credentials and Signal keys cannot re-enter removed SQLite state through tests or older host shims. */ export async function createPersistenceAuthState(persistence: WhatsAppPersistence): Promise { const storedCredentials = await persistence.loadCredentials(); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/index.ts b/plugins/fusion-plugin-whatsapp-chat/src/index.ts index 3803e9a408..8a1a86d0fc 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/index.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/index.ts @@ -3,13 +3,12 @@ import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteRes import { WhatsAppConnection } from "./connection.js"; import { generateReply } from "./reply.js"; import { createWhatsAppPersistence } from "./persistence.js"; -export { claimMessage, loadHistory, saveHistory, wasProcessed, markProcessed } from "./persistence.js"; -export type { ChatTurn, PluginDb } from "./persistence.js"; +export type { ChatTurn } from "./persistence.js"; const DEFAULT_HISTORY_TURN_LIMIT = 40; const DEFAULT_DEDUPE_RETENTION_DAYS = 7; -import type { ChatTurn, PluginDb } from "./persistence.js"; +import type { ChatTurn } from "./persistence.js"; const settingsSchema: Record = { pairingMode: { @@ -80,36 +79,6 @@ export function splitMessageForWhatsapp(text: string): string[] { return WhatsAppConnection.splitMessageForWhatsapp(text); } -export function ensureSchema(db: PluginDb): void { - db.exec(` - CREATE TABLE IF NOT EXISTS whatsapp_chat_sessions ( - sender TEXT PRIMARY KEY, - history TEXT NOT NULL, - updatedAt TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS whatsapp_chat_dedupe ( - messageId TEXT PRIMARY KEY, - sender TEXT NOT NULL, - receivedAt TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS whatsapp_auth_creds ( - id TEXT PRIMARY KEY, - value TEXT NOT NULL, - updatedAt TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS whatsapp_auth_keys ( - category TEXT NOT NULL, - keyId TEXT NOT NULL, - value TEXT NOT NULL, - updatedAt TEXT NOT NULL, - PRIMARY KEY (category, keyId) - ); - `); -} - function getConnectionOrResponse(ctx: PluginContext): { connection?: WhatsAppConnection; error?: PluginRouteResponse } { const connection = connections.get(getConnectionKey(ctx)); if (!connection) { @@ -188,9 +157,6 @@ const plugin: FusionPlugin = definePlugin({ state: "installed", routes, hooks: { - onSchemaInit: (db) => { - ensureSchema(db as PluginDb); - }, onLoad: async (ctx) => { const persistence = createWhatsAppPersistence(ctx); const connection = new WhatsAppConnection(ctx, plugin.manifest.version, generateReply, persistence); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts b/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts index 65e7919530..36da16e3f1 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts @@ -3,10 +3,6 @@ import { sql } from "drizzle-orm"; export type ChatTurn = { role: "user" | "assistant"; text: string; createdAt: string }; export type AuthKeyBatch = Record>; -export type PluginDb = { - exec(sql: string): void; - prepare(sql: string): { get(...args: unknown[]): unknown; run(...args: unknown[]): unknown }; -}; const DAY_MS = 86_400_000; @@ -33,153 +29,14 @@ function parseHistory(raw: string | null | undefined): ChatTurn[] { } } -export function loadHistory(db: PluginDb, sender: string): ChatTurn[] { - const row = db.prepare("SELECT history FROM whatsapp_chat_sessions WHERE sender = ?").get(sender) as { history?: string } | undefined; - return parseHistory(row?.history); -} - -export function saveHistory(db: PluginDb, sender: string, history: ChatTurn[]): void { - const now = new Date().toISOString(); - db.prepare(`INSERT INTO whatsapp_chat_sessions(sender, history, updatedAt) VALUES(?, ?, ?) - ON CONFLICT(sender) DO UPDATE SET history = excluded.history, updatedAt = excluded.updatedAt`) - .run(sender, JSON.stringify(history), now); -} - -function withImmediateTransaction(db: PluginDb, operation: () => T): T { - db.exec("BEGIN IMMEDIATE"); - try { - const result = operation(); - db.exec("COMMIT"); - return result; - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } -} - /** - * FNXC:WhatsAppConcurrentHistory 2026-07-14-00:42: - * Concurrent deliveries from one sender must append complete user/assistant turns instead of replacing a stale history snapshot. Keep the read, bounded append, and write in one immediate transaction; the connection also serializes reply generation per sender so each reply observes every earlier delivered turn. - */ -export function appendHistory( - db: PluginDb, - sender: string, - turns: ChatTurn[], - turnLimit: number, -): void { - withImmediateTransaction(db, () => { - const history = [...loadHistory(db, sender), ...turns].slice(-turnLimit); - saveHistory(db, sender, history); - }); -} - -export function wasProcessed(db: PluginDb, messageId: string): boolean { - return Boolean(db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get(messageId)); -} - -export function markProcessed(db: PluginDb, messageId: string, sender: string, retentionDays = 7): void { - claimMessage(db, messageId, sender, retentionDays); -} - -/** - * FNXC:WhatsAppReplayClaim 2026-07-13-23:40: - * Duplicate deliveries can reach concurrent EventEmitter callbacks. Claim a message with one uniqueness-enforced insert and process it only when that insert wins; a separate read followed by insert permits both callbacks to generate and send a reply. - */ -export function claimMessage( - db: PluginDb, - messageId: string, - sender: string, - retentionDays = 7, -): boolean { - const now = new Date().toISOString(); - const cutoff = new Date(Date.now() - retentionDays * DAY_MS).toISOString(); - db.prepare("DELETE FROM whatsapp_chat_dedupe WHERE receivedAt < ?").run(cutoff); - const result = db - .prepare("INSERT OR IGNORE INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)") - .run(messageId, sender, now) as { changes?: number }; - return Number(result.changes ?? 0) === 1; -} - -export function createSqliteWhatsAppPersistence(db: PluginDb): WhatsAppPersistence { - return { - async loadHistory(sender) { - return loadHistory(db, sender); - }, - async appendHistory(sender, turns, turnLimit) { - appendHistory(db, sender, turns, turnLimit); - }, - async wasProcessed(messageId) { - return wasProcessed(db, messageId); - }, - async markProcessed(messageId, sender, retentionDays) { - markProcessed(db, messageId, sender, retentionDays); - }, - async claimMessage(messageId, sender, retentionDays) { - return claimMessage(db, messageId, sender, retentionDays); - }, - async loadCredentials() { - const row = db.prepare("SELECT value FROM whatsapp_auth_creds WHERE id = 'creds'").get() as { value?: string } | undefined; - return row?.value ?? null; - }, - async saveCredentials(value) { - db.prepare(`INSERT INTO whatsapp_auth_creds(id, value, updatedAt) VALUES('creds', ?, ?) - ON CONFLICT(id) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`) - .run(value, new Date().toISOString()); - }, - async loadAuthKeys(category, ids) { - const result: Record = {}; - const select = db.prepare("SELECT value FROM whatsapp_auth_keys WHERE category = ? AND keyId = ?"); - for (const id of ids) { - const row = select.get(category, id) as { value?: string } | undefined; - if (row?.value !== undefined) result[id] = row.value; - } - return result; - }, - async writeAuthKeys(batch) { - /** - * FNXC:WhatsAppAuthKeyAtomicity 2026-07-14-01:21: - * A Baileys Signal-key update can rotate several categories in one logical batch. Commit every category's deletes and upserts together so a later category failure cannot leave an earlier category partially rotated. - */ - withImmediateTransaction(db, () => { - const upsert = db.prepare(`INSERT INTO whatsapp_auth_keys(category, keyId, value, updatedAt) VALUES(?, ?, ?, ?) - ON CONFLICT(category, keyId) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`); - const remove = db.prepare("DELETE FROM whatsapp_auth_keys WHERE category = ? AND keyId = ?"); - const now = new Date().toISOString(); - for (const [category, values] of Object.entries(batch)) { - for (const [id, value] of Object.entries(values)) { - if (value === null) remove.run(category, id); - else upsert.run(category, id, value, now); - } - } - }); - }, - async clearAuthState() { - /** - * FNXC:WhatsAppAuthStateAtomicity 2026-07-14-00:54: - * Credentials and Signal keys form one authentication state. Clear both in one immediate transaction so a failed or interrupted second delete cannot persist credentials without their matching keys, or vice versa. - */ - withImmediateTransaction(db, () => { - db.prepare("DELETE FROM whatsapp_auth_creds").run(); - db.prepare("DELETE FROM whatsapp_auth_keys").run(); - }); - }, - }; -} - -/** - * FNXC:WhatsAppPostgresPersistence 2026-07-13-22:37: - * Backend-mode WhatsApp state must use the bound AsyncDataLayer instead of reaching through PluginStore for its former private SQLite database. Every statement includes project_id because bundled plugins from all projects share the same project schema. + * FNXC:WhatsAppPostgresPersistence 2026-07-14-18:05: + * WhatsApp runtime and Baileys auth state require the TaskStore's project-bound AsyncDataLayer. There is no PluginStore/private-database or SQLite compatibility branch: unavailable or unbound PostgreSQL state fails before the connection starts, and every statement includes project_id because all projects share this schema. */ export function createWhatsAppPersistence(ctx: PluginContext): WhatsAppPersistence { const layer = typeof ctx.taskStore.getAsyncLayer === "function" ? ctx.taskStore.getAsyncLayer() : null; - if (!layer) { - const pluginStore = ctx.taskStore.getPluginStore(); - const db = (pluginStore as unknown as { db?: PluginDb }).db; - if (!db) throw new Error("Plugin database unavailable"); - return createSqliteWhatsAppPersistence(db); - } - - const projectId = layer.projectId; + if (!layer) throw new Error("WhatsApp plugin requires a PostgreSQL AsyncDataLayer"); + const projectId = layer.projectId?.trim(); if (!projectId) throw new Error("WhatsApp PostgreSQL persistence requires a project-bound data layer"); const db = layer.db; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee4585f566..16339a67cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -980,6 +980,9 @@ importers: '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.9.0)(pg@8.22.0)(postgres@3.4.9) devDependencies: '@types/node': specifier: ^25.5.2