Files
fusion/plugins/fusion-plugin-compound-engineering/src/schema.ts
gsxdsm a242f1b449 fix(FN-7952): migrate bundled plugins to PostgreSQL (#2111)
## Summary

Bundled plugins now persist shared runtime state in project-scoped
PostgreSQL tables instead of maintaining independent SQLite authority.
Reports, CLI Printing Press, Compound Engineering, Roadmap, Even
Realities, and WhatsApp all follow the same ownership and startup
contract as Fusion core.

## Design decisions

- Plugin schema hooks run through the host’s PostgreSQL owner and
enforce project isolation.
- The SDK exposes the host contract needed by bundled plugins without
importing engine internals.
- Legacy Roadmap ownership fixtures use the supported empty-owner
sentinel, preserving current composite primary/foreign keys while
exercising backfill behavior.
- The lockfile travels with the Even Realities PostgreSQL dependency so
packaged installs remain reproducible.

## Validation

- All six affected plugin builds pass.
- Affected plugin suites pass: 773 tests across Printing Press, Compound
Engineering, Even Realities, Reports, Roadmap, and WhatsApp.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 40 files.

## Stack

- Depends on #2110 → #2109 → #2108.
- The documentation/release PR completes the stack.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Breaking Changes**
* PostgreSQL is now required for runtime storage; SQLite files are used
only as one-time migration inputs.
  * The legacy `FUSION_NO_EMBEDDED_PG` fallback has been removed.

* **New Features**
* Added project-isolated PostgreSQL storage for plugins, reports, tasks,
notifications, and other plugin data.
  * Added agent tools for reports and CLI service drafts.
  * Added PostgreSQL schema initialization support for plugin authors.

* **Bug Fixes**
  * Improved migration and recovery of legacy plugin state.
* Prevented cross-project data access and strengthened transactional
schema updates.

* **Documentation**
* Updated storage, migration, deployment, plugin authoring, CLI, and
dashboard guidance for PostgreSQL.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 00:27:59 -07:00

127 lines
5.0 KiB
TypeScript

import type { Database } from "@fusion/core";
/**
* Idempotent DDL for the Compound Engineering plugin-local tables (U5).
*
* 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
* persisted here so an interrupt/error never destroys progress (lesson:
* docs/incidents/2026-05-23-lost-work-tasks.md). The `currentQuestion` and
* `conversationHistory` columns are JSON; resume reconstructs the awaiting
* question and full history from them.
*
* `lastActivityAt` is an interval-relative liveness field (epoch millis of the
* last produced event). Staleness is judged relative to the session's
* configured turn interval, NOT by raw last-event age, so a healthy-but-slow
* agent turn is not misclassified stale (docs/fn-4172-heartbeat-investigation.md).
*
* `ce_pipeline_links` (U7) is the addressable back-reference table: it links a
* board task to the CE pipeline/stage/artifact that produced it. Per FN-5719 the
* back-reference lives in this plugin-local table (NOT in task-row JSON) so
* board-task ownership and CE-pipeline ownership stay separate state machines.
* U7 keeps it minimal (link records only); U8 extends it with the bidirectional
* pipeline-state machine.
*
* `ce_pipeline_state` (U8) is the CE-pipeline's OWN state machine — DISTINCT from
* board-task column state (KTD4 / FN-5719: two separate ownership state machines,
* never one shared column encoding two concerns). It tracks where the pipeline
* itself is: `currentStage` (the CE stage the pipeline has reached) and `status`
* (`running` | `advancing` | `awaiting_board` | `completed`). The board owns task
* columns; this table owns pipeline progress. They are reconciled — never merged.
*
* `ce_pipeline_sync_queue` (U8) is the event-enqueue seam (FN-5719). Lifecycle
* hooks write a row here FAST (5s hook budget) and return; the reconciler drains
* it. A dropped/never-enqueued event is still recovered because the reconciler
* ALSO re-derives transitions from board state — the queue is an optimization,
* board+state comparison is the convergence guarantee. `processedAt NULL` =
* pending; non-null = drained (kept for audit, swept idempotently).
*/
export function ensureCeSchema(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS ce_sessions (
id TEXT PRIMARY KEY,
stage TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'launching','active','awaiting_input','completed','error','interrupted'
)),
currentQuestion TEXT,
conversationHistory TEXT NOT NULL DEFAULT '[]',
projectId TEXT,
artifactPath TEXT,
error TEXT,
turnIntervalMs INTEGER NOT NULL DEFAULT 120000,
lastActivityAt INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxCeSessionsStatusUpdated
ON ce_sessions(status, updatedAt DESC, id);
CREATE INDEX IF NOT EXISTS idxCeSessionsStageCreated
ON ce_sessions(stage, createdAt DESC, id);
CREATE INDEX IF NOT EXISTS idxCeSessionsProject
ON ce_sessions(projectId, updatedAt DESC, id);
CREATE TABLE IF NOT EXISTS ce_plan_handoff_claims (
artifactPath TEXT PRIMARY KEY,
sessionId TEXT NOT NULL UNIQUE,
projectId TEXT,
createdAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxCePlanHandoffClaimsSession
ON ce_plan_handoff_claims(sessionId);
CREATE TABLE IF NOT EXISTS ce_pipeline_links (
id TEXT PRIMARY KEY,
taskId TEXT NOT NULL,
cePipelineId TEXT NOT NULL,
ceStageId TEXT NOT NULL,
ceArtifactPath TEXT,
createdAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxCePipelineLinksPipeline
ON ce_pipeline_links(cePipelineId, createdAt DESC, id);
CREATE UNIQUE INDEX IF NOT EXISTS idxCePipelineLinksTask
ON ce_pipeline_links(taskId);
CREATE TABLE IF NOT EXISTS ce_pipeline_state (
cePipelineId TEXT PRIMARY KEY,
currentStage TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'running','advancing','awaiting_board','completed'
)),
lastArtifactPath TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxCePipelineStateStatus
ON ce_pipeline_state(status, updatedAt DESC, cePipelineId);
CREATE TABLE IF NOT EXISTS ce_pipeline_sync_queue (
id TEXT PRIMARY KEY,
cePipelineId TEXT NOT NULL,
taskId TEXT NOT NULL,
reason TEXT NOT NULL,
fromColumn TEXT,
toColumn TEXT,
enqueuedAt TEXT NOT NULL,
processedAt TEXT
);
CREATE INDEX IF NOT EXISTS idxCePipelineSyncQueuePending
ON ce_pipeline_sync_queue(processedAt, enqueuedAt, id);
CREATE INDEX IF NOT EXISTS idxCePipelineSyncQueuePipeline
ON ce_pipeline_sync_queue(cePipelineId, enqueuedAt, id);
`);
}