## 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 -->
203 lines
8.7 KiB
TypeScript
203 lines
8.7 KiB
TypeScript
import { definePlugin } from "@fusion/plugin-sdk";
|
|
import { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
|
import { installBundledCeSkills, resolveDefaultInstallTargetRoot } from "./skill-installation.js";
|
|
import {
|
|
installBundledCeAgents,
|
|
resolveDefaultAgentsInstallTargetRoot,
|
|
} from "./agent-installation.js";
|
|
import { ensureCeSchema } from "./schema.js";
|
|
import { createSessionRoutes } from "./routes/session-routes.js";
|
|
import { createArtifactRoutes } from "./routes/artifact-routes.js";
|
|
import { recoverStaleSessionsForContext } from "./session/session-recovery.js";
|
|
import { getCePipelineStore } from "./sync/pipeline-store.js";
|
|
import { reconcileCePipelines } from "./sync/reconciler.js";
|
|
import { settingsSchema } from "./settings.js";
|
|
import { getReconcileOnHooks } from "./settings.js";
|
|
|
|
export { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
|
export {
|
|
installBundledCeSkills,
|
|
resolveBundledSkillsRoot,
|
|
resolveDefaultInstallTargetRoot,
|
|
isPluginLocalPath,
|
|
} from "./skill-installation.js";
|
|
export {
|
|
installBundledCeAgents,
|
|
resolveBundledAgentsRoot,
|
|
resolveDefaultAgentsInstallTargetRoot,
|
|
isPluginLocalAgentsPath,
|
|
} from "./agent-installation.js";
|
|
export { ensureCeSchema } from "./schema.js";
|
|
export { CeSessionStore, getCeSessionStore } from "./session/session-store.js";
|
|
export { CePipelineStore, getCePipelineStore } from "./sync/pipeline-store.js";
|
|
export type {
|
|
CePipelineLink,
|
|
CreateCePipelineLinkInput,
|
|
CePipelineState,
|
|
CePipelineStatus,
|
|
CeSyncQueueEntry,
|
|
CeSyncReason,
|
|
} from "./sync/pipeline-store.js";
|
|
export { CeReconciler, reconcileCePipelines } from "./sync/reconciler.js";
|
|
export type { ReconcileResult } from "./sync/reconciler.js";
|
|
export {
|
|
CeOrchestrator,
|
|
WORK_STAGE_ID,
|
|
CE_PLUGIN_ID,
|
|
CE_WORK_SOURCE_TYPE,
|
|
} from "./session/orchestrator.js";
|
|
export { getStage, listPipelineStages, listStages, registerStage } from "./session/stage-registry.js";
|
|
export {
|
|
settingsSchema,
|
|
getDefaultProvider,
|
|
getDefaultModelId,
|
|
getDisabledStages,
|
|
getEnabledStages,
|
|
getReconcileOnHooks,
|
|
getReconcileIntervalMinutes,
|
|
} from "./settings.js";
|
|
|
|
const plugin = definePlugin({
|
|
manifest: {
|
|
id: "fusion-plugin-compound-engineering",
|
|
name: "Compound Engineering",
|
|
version: "0.1.0",
|
|
description: "A dedicated dashboard surface for compound-engineering artifacts and interactive ce-* sessions.",
|
|
author: "Fusion Team",
|
|
fusionVersion: ">=0.1.0",
|
|
skills: COMPOUND_ENGINEERING_SKILLS.map((s) => ({ skillId: s.skillId, name: s.name })),
|
|
settingsSchema,
|
|
},
|
|
state: "installed",
|
|
skills: COMPOUND_ENGINEERING_SKILLS,
|
|
hooks: {
|
|
// 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,
|
|
// ENQUEUE a sync signal, and return. Heavy advancement (board reads, outbound
|
|
// task creation) happens in the reconciler, NOT inline here. A reconcile
|
|
// drain is fired-and-forgotten (never awaited) so a slow sweep cannot blow
|
|
// the hook budget; correctness does not depend on it firing because the next
|
|
// reconcile() sweep re-derives the transition from board truth.
|
|
onTaskMoved: async (task, fromColumn, toColumn, ctx) => {
|
|
const store = getCePipelineStore(ctx);
|
|
// Use the async sibling so backend (PG) mode queries Drizzle instead of
|
|
// throwing on the null sync Database. In SQLite mode it falls back to the
|
|
// sync path internally.
|
|
const link = await store.findByTaskIdAsync(task.id);
|
|
if (!link) return; // not a CE-linked task → ignore fast.
|
|
await store.enqueueSyncAsync({
|
|
cePipelineId: link.cePipelineId,
|
|
taskId: task.id,
|
|
reason: "task_moved",
|
|
fromColumn,
|
|
toColumn,
|
|
});
|
|
// Setting-gated auto-drain (U9): when disabled, the enqueue still happens
|
|
// so an on-demand reconcile (route/refresh) converges later; we just skip
|
|
// the inline sweep.
|
|
if (!getReconcileOnHooks(ctx.settings)) return;
|
|
void reconcileCePipelines(ctx)
|
|
.catch((err) => ctx.logger.warn(`CE reconcile (onTaskMoved) failed: ${String(err)}`));
|
|
},
|
|
onTaskCompleted: async (task, ctx) => {
|
|
const store = getCePipelineStore(ctx);
|
|
const link = await store.findByTaskIdAsync(task.id);
|
|
if (!link) return;
|
|
await store.enqueueSyncAsync({
|
|
cePipelineId: link.cePipelineId,
|
|
taskId: task.id,
|
|
reason: "task_completed",
|
|
toColumn: "done",
|
|
});
|
|
if (!getReconcileOnHooks(ctx.settings)) return;
|
|
void reconcileCePipelines(ctx)
|
|
.catch((err) => ctx.logger.warn(`CE reconcile (onTaskCompleted) failed: ${String(err)}`));
|
|
},
|
|
// Install the bundled, pinned ce-* SKILL.md files into a plugin-local,
|
|
// discoverable directory on load. The engine ingests
|
|
// PluginSkillContribution only as a name; physical discovery requires the
|
|
// files to exist on a path it scans (U2 finding). Install is provenance-aware:
|
|
// current plugin-local copies are skipped, stale/unmarked copies are refreshed,
|
|
// and global ~/.claude/skills is never touched.
|
|
onLoad: async (ctx) => {
|
|
try {
|
|
const { targetRoot, results } = installBundledCeSkills();
|
|
const installed = results.filter((r) => r.outcome === "installed").length;
|
|
const refreshed = results.filter((r) => r.outcome === "refreshed").length;
|
|
const errored = results.filter((r) => r.outcome === "error");
|
|
if (errored.length > 0) {
|
|
ctx.logger.warn(
|
|
`Compound Engineering: ${errored.length} skill(s) failed to install: ${errored
|
|
.map((e) => `${e.skillId} (${e.reason})`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
ctx.logger.info(
|
|
`Compound Engineering skills ready — installed=${installed} refreshed=${refreshed} target=${targetRoot}`,
|
|
);
|
|
ctx.emitEvent("compound-engineering:skills-installed", { targetRoot, results });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
ctx.logger.error(`Compound Engineering skill install failed: ${message}`);
|
|
}
|
|
|
|
// Install the bundled ce-* persona definitions (same posture as skills:
|
|
// pinned, plugin-local, provenance-refreshed, never a global ~/.claude/agents).
|
|
// The CE skills read these and pass them to fn_spawn_agent.systemPromptOverride.
|
|
try {
|
|
const { targetRoot, results } = installBundledCeAgents();
|
|
const installed = results.filter((r) => r.outcome === "installed").length;
|
|
const refreshed = results.filter((r) => r.outcome === "refreshed").length;
|
|
const errored = results.filter((r) => r.outcome === "error");
|
|
if (errored.length > 0) {
|
|
ctx.logger.warn(
|
|
`Compound Engineering: ${errored.length} agent def(s) failed to install: ${errored
|
|
.map((e) => `${e.agentId} (${e.reason})`)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
ctx.logger.info(
|
|
`Compound Engineering agent personas ready — installed=${installed} refreshed=${refreshed} target=${targetRoot}`,
|
|
);
|
|
ctx.emitEvent("compound-engineering:agents-installed", { targetRoot, results });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
ctx.logger.error(`Compound Engineering agent install failed: ${message}`);
|
|
}
|
|
|
|
await recoverStaleSessionsForContext(ctx, { reason: "load", force: true, emitEvent: true });
|
|
},
|
|
},
|
|
// Expose the plugin-local ce-* persona-definition directory to executor /
|
|
// workflow-step sessions via FUSION_CE_AGENTS_DIR. The CE skills read a persona
|
|
// def from here and pass its body to fn_spawn_agent's systemPromptOverride —
|
|
// the lightweight subagent path (no plugin agent-contribution channel exists).
|
|
// Defs are installed in onLoad.
|
|
executorRuntimeEnv: () => ({
|
|
env: {
|
|
FUSION_CE_AGENTS_DIR: resolveDefaultAgentsInstallTargetRoot(),
|
|
// Step sessions run with cwd=projectRoot, so a CE skill can't reach its
|
|
// bundled scripts via a relative `scripts/...` path. Expose the installed
|
|
// skills root so skill docs can invoke scripts by absolute path
|
|
// (`"$FUSION_CE_SKILLS_DIR/<skill-id>/scripts/<name>"`).
|
|
FUSION_CE_SKILLS_DIR: resolveDefaultInstallTargetRoot(),
|
|
},
|
|
description: "compound-engineering ce-* persona definitions and bundled skills directories",
|
|
}),
|
|
routes: [...createSessionRoutes(), ...createArtifactRoutes()],
|
|
dashboardViews: [
|
|
{
|
|
viewId: "compound-engineering",
|
|
label: "Compound Engineering",
|
|
componentPath: "./dashboard-view",
|
|
icon: "Boxes",
|
|
placement: "primary",
|
|
order: 36,
|
|
},
|
|
],
|
|
});
|
|
|
|
export default plugin;
|