feat(FN-3182): scope plugin installs globally with project enablement

- Store plugin installation metadata in central DB while preserving per-project enable/disable state
- Update plugin CLI and dashboard Plugin Manager copy/behavior to distinguish global install from project enablement
- Expand plugin store and loader test coverage, including legacy migration and scoped install assertions
- Update runtime plugin e2e tests to use isolated central DB directories and document the new semantics
- Add a changeset for @runfusion/fusion describing the plugin scope behavior change

Fusion-Task-Id: FN-3182
This commit is contained in:
Fusion
2026-05-07 22:11:53 -07:00
committed by gsxdsm
parent 979cf115d9
commit b7f68d7dc0
20 changed files with 617 additions and 279 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Plugin management now separates global installation from project activation: installs/uninstalls are global, while enable/disable and runtime state remain project-scoped. Updated dashboard plugin lifecycle SSE payloads and Plugin Manager/CLI copy to make global vs project scope explicit.

View File

@@ -301,8 +301,11 @@ Hybrid evaluator pipeline (FN-3389/FN-3391):
### Plugin System
- `PluginStore` (`plugin-store.ts`) stores plugin installation state and settings (`plugins` table)
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules and emits lifecycle events
- `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
- **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
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules using the effective per-project plugin state
- Plugin contributions now include both embedded `uiSlots` and top-level `dashboardViews`
- Discovery endpoints:
- `GET /api/plugins/ui-slots`

View File

@@ -828,6 +828,11 @@ fn plugin create <name>
Subcommands: `list|ls`, `install`, `rescan`, `uninstall`, `enable`, `disable`, `create`.
Scope semantics:
- `fn plugin install` / `fn plugin uninstall` are **global** operations
- `fn plugin enable` / `fn plugin disable` are **project-scoped** operations (`--project` selects the project context)
- `fn plugin list` shows globally installed plugins plus enabled/disabled state for the current project context
`fn plugin install --ai-scan` enables AI security scanning on plugin load. `fn plugin rescan <id>` runs a fresh scan/reload cycle and prints plugin name, verdict, summary, and finding count. It exits non-zero for `blocked`, `error`, or `unavailable` verdicts.
---

View File

@@ -80,6 +80,18 @@ Central health tracking keeps mutable project metrics, including:
A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots.
## Plugin Scope in Multi-Project Mode
Plugin persistence is split across global and project scopes:
- Global installation metadata is shared across projects in `~/.fusion/fusion-central.db` (`plugin_installs`)
- Per-project activation/runtime state is tracked separately per normalized project path (`project_plugin_states`)
Operationally:
- `install` / `uninstall` are global actions
- `enable` / `disable` and runtime state/error are project-scoped
- A single global plugin install can be enabled in one project and disabled in another
## Isolation Modes
Projects can run with:

View File

@@ -573,6 +573,8 @@ The fallback ensures tasks continue executing even if the configured runtime plu
To use plugin-provided runtimes like Paperclip, Hermes, or OpenClaw:
> Scope model: plugin installation + plugin settings are global (shared across projects), while plugin enabled/disabled state and runtime status are project-scoped.
1. Install one or more runtime plugins:
```bash

View File

@@ -178,7 +178,7 @@ export async function runPluginList(projectName?: string): Promise<void> {
}
console.log();
console.log(" ID Name Version State Enabled");
console.log(" ID Name Version State Project Enabled");
console.log(" ─────────────────────────────────────────────────────────────────────");
for (const plugin of plugins) {
@@ -221,7 +221,7 @@ export async function runPluginInstall(
const { manifest, path } = await loadManifestFromPath(source);
console.log();
console.log(` Installing ${manifest.name} v${manifest.version}...`);
console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
// Register the plugin
const plugin = await store.registerPlugin({
@@ -234,12 +234,12 @@ export async function runPluginInstall(
if (plugin.enabled) {
try {
await loader.loadPlugin(plugin.id);
console.log(`${manifest.name} installed and loaded`);
console.log(`${manifest.name} installed globally and enabled for this project`);
} catch (loadErr) {
console.log(`${manifest.name} installed but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`);
}
} else {
console.log(`${manifest.name} installed (disabled)`);
console.log(`${manifest.name} installed globally (disabled for this project)`);
}
console.log();
} catch (err) {
@@ -272,8 +272,8 @@ export async function runPluginUninstall(
// Confirm unless force
if (!options?.force) {
console.log();
console.log(` Uninstall "${plugin.name}"?`);
console.log(` This will stop and remove the plugin.`);
console.log(` Uninstall "${plugin.name}" globally?`);
console.log(" This removes it for all projects.");
console.log();
const response = await new Promise<string>((resolve) => {
@@ -304,7 +304,7 @@ export async function runPluginUninstall(
await store.unregisterPlugin(id);
console.log();
console.log(`${plugin.name} uninstalled`);
console.log(`${plugin.name} uninstalled globally`);
console.log();
}
@@ -346,7 +346,7 @@ export async function runPluginEnable(
}
console.log();
console.log(`${plugin.name} enabled and started`);
console.log(`${plugin.name} enabled for this project and started`);
console.log();
}
@@ -381,7 +381,7 @@ export async function runPluginDisable(
await store.disablePlugin(id);
console.log();
console.log(`${plugin.name} disabled and stopped`);
console.log(`${plugin.name} disabled for this project and stopped`);
console.log();
}

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
});
it("should seed lastModified on init", () => {
@@ -217,7 +217,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -282,7 +282,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -370,7 +370,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
@@ -520,7 +520,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(8);
expect(db.getSchemaVersion()).toBe(9);
const mappings = db
.prepare("SELECT projectId, nodeId, path FROM projectNodePathMappings ORDER BY projectId")

View File

@@ -38,7 +38,7 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () =>
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "kb-plugin-loader-contrib-"));
pluginStore = new PluginStore(rootDir, { inMemoryDb: true });
pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as any });
});

View File

@@ -165,7 +165,7 @@ describe("PluginLoader", () => {
beforeEach(() => {
rootDir = makeTmpDir();
pluginStore = new PluginStore(rootDir, { inMemoryDb: true });
pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
setCreateAiSessionFactory(undefined);
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PluginStore } from "../plugin-store.js";
import { Database, toJson } from "../db.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
@@ -20,19 +21,66 @@ function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
};
}
function seedLegacyPluginRow(
projectRoot: string,
row: {
id: string;
name: string;
version: string;
path: string;
enabled?: number;
state?: PluginState;
error?: string | null;
settings?: Record<string, unknown>;
updatedAt?: string;
},
): void {
const db = new Database(join(projectRoot, ".fusion"));
db.init();
const now = row.updatedAt ?? new Date().toISOString();
db.prepare(`
INSERT INTO plugins (
id, name, version, description, author, homepage, path,
enabled, state, settings, settingsSchema, error, dependencies,
aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
row.id,
row.name,
row.version,
null,
null,
null,
row.path,
row.enabled ?? 1,
row.state ?? "installed",
toJson(row.settings ?? {}),
null,
row.error ?? null,
toJson([]),
0,
null,
now,
now,
);
}
describe("PluginStore", () => {
let rootDir: string;
let store: PluginStore;
let centralDir: string;
beforeEach(async () => {
rootDir = makeTmpDir();
// In-memory SQLite for test speed; see store.test.ts beforeEach.
store = new PluginStore(rootDir, { inMemoryDb: true });
centralDir = makeTmpDir();
// In-memory project DB + isolated central DB directory.
store = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: centralDir });
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
await rm(centralDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
@@ -41,7 +89,7 @@ describe("PluginStore", () => {
it("creates the database file", async () => {
// Asserts a real file on disk exists, which the in-memory
// beforeEach store can't satisfy — open a disk-backed store.
const diskStore = new PluginStore(rootDir);
const diskStore = new PluginStore(rootDir, { centralGlobalDir: centralDir });
await diskStore.init();
const dbPath = join(rootDir, ".fusion", "fusion.db");
const { existsSync } = await import("node:fs");
@@ -63,6 +111,103 @@ describe("PluginStore", () => {
});
});
describe("migration", () => {
it("migrates legacy project plugin rows into central install and project state", async () => {
const migrationProject = makeTmpDir();
const migrationCentral = makeTmpDir();
try {
seedLegacyPluginRow(migrationProject, {
id: "legacy-plugin",
name: "Legacy Plugin",
version: "1.2.3",
path: "/legacy/path",
enabled: 0,
state: "error",
error: "boom",
settings: { token: "abc" },
});
const migrationStore = new PluginStore(migrationProject, { centralGlobalDir: migrationCentral });
await migrationStore.init();
const plugin = await migrationStore.getPlugin("legacy-plugin");
expect(plugin.path).toBe("/legacy/path");
expect(plugin.enabled).toBe(false);
expect(plugin.state).toBe("error");
expect(plugin.error).toBe("boom");
expect(plugin.settings).toEqual({ token: "abc" });
} finally {
await rm(migrationProject, { recursive: true, force: true });
await rm(migrationCentral, { recursive: true, force: true });
}
});
it("is idempotent when init runs multiple times", async () => {
const migrationProject = makeTmpDir();
const migrationCentral = makeTmpDir();
try {
seedLegacyPluginRow(migrationProject, {
id: "legacy-idempotent",
name: "Legacy Idempotent",
version: "1.0.0",
path: "/legacy/idempotent",
});
const migrationStore = new PluginStore(migrationProject, { centralGlobalDir: migrationCentral });
await migrationStore.init();
await migrationStore.init();
const plugins = await migrationStore.listPlugins();
expect(plugins.filter((plugin) => plugin.id === "legacy-idempotent")).toHaveLength(1);
} finally {
await rm(migrationProject, { recursive: true, force: true });
await rm(migrationCentral, { recursive: true, force: true });
}
});
it("keeps latest updatedAt install metadata across projects while preserving per-project enablement", async () => {
const projectA = makeTmpDir();
const projectB = makeTmpDir();
const sharedCentral = makeTmpDir();
try {
seedLegacyPluginRow(projectA, {
id: "shared-legacy",
name: "Shared Legacy Old",
version: "1.0.0",
path: "/old/path",
enabled: 1,
updatedAt: "2026-01-01T00:00:00.000Z",
});
seedLegacyPluginRow(projectB, {
id: "shared-legacy",
name: "Shared Legacy New",
version: "2.0.0",
path: "/new/path",
enabled: 0,
updatedAt: "2026-02-01T00:00:00.000Z",
});
const storeA = new PluginStore(projectA, { centralGlobalDir: sharedCentral });
const storeB = new PluginStore(projectB, { centralGlobalDir: sharedCentral });
await storeA.init();
await storeB.init();
const pluginFromA = await storeA.getPlugin("shared-legacy");
const pluginFromB = await storeB.getPlugin("shared-legacy");
expect(pluginFromA.name).toBe("Shared Legacy New");
expect(pluginFromA.version).toBe("2.0.0");
expect(pluginFromA.path).toBe("/new/path");
expect(pluginFromA.enabled).toBe(true);
expect(pluginFromB.enabled).toBe(false);
} finally {
await rm(projectA, { recursive: true, force: true });
await rm(projectB, { recursive: true, force: true });
await rm(sharedCentral, { recursive: true, force: true });
}
});
});
// ── registerPlugin ─────────────────────────────────────────────────
describe("registerPlugin", () => {

View File

@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 8;
const CENTRAL_SCHEMA_VERSION = 9;
const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry)
@@ -177,6 +177,39 @@ CREATE TABLE IF NOT EXISTS managedDockerNodes (
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status);
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
-- Global plugin install registry
CREATE TABLE IF NOT EXISTS plugin_installs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
author TEXT,
homepage TEXT,
path TEXT NOT NULL,
settings TEXT DEFAULT '{}',
settingsSchema TEXT,
dependencies TEXT DEFAULT '[]',
aiScanOnLoad INTEGER NOT NULL DEFAULT 0,
lastSecurityScan TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
-- Per-project plugin state
CREATE TABLE IF NOT EXISTS project_plugin_states (
projectPath TEXT NOT NULL,
pluginId TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'installed',
error TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (projectPath, pluginId),
FOREIGN KEY (pluginId) REFERENCES plugin_installs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesProjectPath ON project_plugin_states(projectPath);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesPluginId ON project_plugin_states(pluginId);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
@@ -285,6 +318,39 @@ CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsProjectId ON projectNodePat
CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsNodeId ON projectNodePathMappings(nodeId);
`;
const CENTRAL_SCHEMA_V9_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS plugin_installs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
author TEXT,
homepage TEXT,
path TEXT NOT NULL,
settings TEXT DEFAULT '{}',
settingsSchema TEXT,
dependencies TEXT DEFAULT '[]',
aiScanOnLoad INTEGER NOT NULL DEFAULT 0,
lastSecurityScan TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_plugin_states (
projectPath TEXT NOT NULL,
pluginId TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'installed',
error TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (projectPath, pluginId),
FOREIGN KEY (pluginId) REFERENCES plugin_installs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesProjectPath ON project_plugin_states(projectPath);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesPluginId ON project_plugin_states(pluginId);
`;
// ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase {
@@ -407,6 +473,11 @@ export class CentralDatabase {
migrated = true;
}
if (currentVersion < 9) {
this.db.exec(CENTRAL_SCHEMA_V9_MIGRATION_SQL);
migrated = true;
}
if (migrated) {
this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")

View File

@@ -1,12 +1,14 @@
/**
* SQLite-backed PluginStore for managing plugin installations.
*
* Provides CRUD operations for plugins with event emission for state changes.
* Global install metadata is persisted in central DB, while per-project
* enablement/runtime state is persisted per project path.
*/
import { EventEmitter } from "node:events";
import { join } from "node:path";
import { Database, toJson, fromJson } from "./db.js";
import { join, resolve } from "node:path";
import { Database, fromJson, toJson } from "./db.js";
import { CentralDatabase } from "./central-db.js";
import type {
PluginInstallation,
PluginManifest,
@@ -26,7 +28,6 @@ export interface PluginStoreEvents {
"plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState];
}
/** Input for registering a new plugin */
export interface PluginRegistrationInput {
manifest: PluginManifest;
path: string;
@@ -34,7 +35,6 @@ export interface PluginRegistrationInput {
aiScanOnLoad?: boolean;
}
/** Partial update input for a plugin */
export interface PluginUpdateInput {
name?: string;
version?: string;
@@ -47,8 +47,7 @@ export interface PluginUpdateInput {
lastSecurityScan?: PluginSecurityScanResult;
}
/** Database row shape for the plugins table. */
interface PluginRow {
interface LegacyPluginRow {
id: string;
name: string;
version: string;
@@ -68,64 +67,75 @@ interface PluginRow {
updatedAt: string;
}
interface InstallRow {
id: string;
name: string;
version: string;
description: string | null;
author: string | null;
homepage: string | null;
path: string;
settings: string | null;
settingsSchema: string | null;
dependencies: string | null;
aiScanOnLoad: number;
lastSecurityScan: string | null;
createdAt: string;
updatedAt: string;
}
interface ProjectStateRow {
projectPath: string;
pluginId: string;
enabled: number;
state: string;
error: string | null;
createdAt: string;
updatedAt: string;
}
export class PluginStore extends EventEmitter<PluginStoreEvents> {
/** SQLite database instance */
private _db: Database | null = null;
private _localDb: Database | null = null;
private _centralDb: CentralDatabase | null = null;
private readonly inMemoryDb: boolean;
private readonly normalizedProjectPath: string;
private readonly centralGlobalDir?: string;
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
constructor(
private rootDir: string,
options?: { inMemoryDb?: boolean; centralGlobalDir?: string },
) {
super();
assertProjectRootDir(rootDir, "PluginStore");
this.inMemoryDb = options?.inMemoryDb === true;
this.normalizedProjectPath = resolve(rootDir);
this.centralGlobalDir = options?.centralGlobalDir;
}
/**
* Get the SQLite database, initializing it on first access.
*/
private get db(): Database {
if (!this._db) {
private get localDb(): Database {
if (!this._localDb) {
const fusionDir = join(this.rootDir, ".fusion");
this._db = new Database(fusionDir, { inMemory: this.inMemoryDb });
this._db.init();
this._localDb = new Database(fusionDir, { inMemory: this.inMemoryDb });
this._localDb.init();
}
return this._db;
return this._localDb;
}
private get centralDb(): CentralDatabase {
if (!this._centralDb) {
this._centralDb = new CentralDatabase(this.centralGlobalDir);
this._centralDb.init();
}
return this._centralDb;
}
/** Initialize the store. */
async init(): Promise<void> {
// Ensure DB is initialized (triggers table creation)
const _ = this.db;
const _ = this.localDb;
const __ = this.centralDb;
this.migrateLegacyProjectRows();
}
// ── Row Conversion ─────────────────────────────────────────────────
private rowToPlugin(row: PluginRow): PluginInstallation {
return {
id: row.id,
name: row.name,
version: row.version,
description: row.description || undefined,
author: row.author || undefined,
homepage: row.homepage || undefined,
path: row.path,
enabled: row.enabled === 1,
state: row.state as PluginState,
settings: fromJson<Record<string, unknown>>(row.settings) || {},
settingsSchema: fromJson<Record<string, PluginSettingSchema>>(row.settingsSchema),
error: row.error || undefined,
dependencies: fromJson<string[]>(row.dependencies) || [],
aiScanOnLoad: row.aiScanOnLoad === 1,
lastSecurityScan: fromJson<PluginSecurityScanResult>(row.lastSecurityScan ?? null) ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Validation Helpers ───────────────────────────────────────────────
private validateIdFormat(id: string): boolean {
// Valid slug: lowercase alphanumeric, hyphens, cannot start/end with hyphen
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id);
}
@@ -138,17 +148,12 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
const errors: string[] = [];
for (const [key, settingSchema] of Object.entries(schema)) {
const value = settings[key];
// Check required
if (settingSchema.required && !(key in settings)) {
errors.push(`Setting "${key}" is required`);
continue;
}
// Skip validation if not provided and not required
if (!(key in settings)) continue;
// Check type
const expectedType = settingSchema.type;
if (expectedType === "string" && typeof value !== "string") {
errors.push(`Setting "${key}" must be a string`);
@@ -160,15 +165,12 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
errors.push(`Setting "${key}" must be a boolean`);
} else if (expectedType === "enum") {
if (typeof value !== "string" || !settingSchema.enumValues?.includes(value)) {
errors.push(
`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`,
);
errors.push(`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`);
}
} else if (expectedType === "array") {
if (!Array.isArray(value)) {
errors.push(`Setting "${key}" must be an array`);
} else {
// Validate item types
const itemType = settingSchema.itemType;
for (const item of value) {
if (itemType === "string" && typeof item !== "string") {
@@ -186,35 +188,187 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
return errors;
}
// ── CRUD Operations ────────────────────────────────────────────────
private rowToPlugin(install: InstallRow, state?: ProjectStateRow): PluginInstallation {
return {
id: install.id,
name: install.name,
version: install.version,
description: install.description || undefined,
author: install.author || undefined,
homepage: install.homepage || undefined,
path: install.path,
enabled: state?.enabled === 1,
state: (state?.state ?? "installed") as PluginState,
settings: fromJson<Record<string, unknown>>(install.settings) || {},
settingsSchema: fromJson<Record<string, PluginSettingSchema>>(install.settingsSchema),
error: state?.error || undefined,
dependencies: fromJson<string[]>(install.dependencies) || [],
aiScanOnLoad: install.aiScanOnLoad === 1,
lastSecurityScan: fromJson<PluginSecurityScanResult>(install.lastSecurityScan ?? null) ?? undefined,
createdAt: install.createdAt,
updatedAt: state?.updatedAt ?? install.updatedAt,
};
}
private getProjectState(pluginId: string): ProjectStateRow | undefined {
return this.centralDb
.prepare("SELECT * FROM project_plugin_states WHERE projectPath = ? AND pluginId = ?")
.get(this.normalizedProjectPath, pluginId) as ProjectStateRow | undefined;
}
private upsertProjectState(
pluginId: string,
updates: { enabled?: boolean; state?: PluginState; error?: string | null },
): ProjectStateRow {
const existing = this.getProjectState(pluginId);
const now = new Date().toISOString();
const row: ProjectStateRow = {
projectPath: this.normalizedProjectPath,
pluginId,
enabled: updates.enabled === undefined ? (existing?.enabled ?? 0) : updates.enabled ? 1 : 0,
state: updates.state ?? existing?.state ?? "installed",
error: updates.error === undefined ? (existing?.error ?? null) : updates.error,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
this.centralDb
.prepare(`
INSERT INTO project_plugin_states (projectPath, pluginId, enabled, state, error, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(projectPath, pluginId) DO UPDATE SET
enabled = excluded.enabled,
state = excluded.state,
error = excluded.error,
updatedAt = excluded.updatedAt
`)
.run(
row.projectPath,
row.pluginId,
row.enabled,
row.state,
row.error,
row.createdAt,
row.updatedAt,
);
return row;
}
private migrateLegacyProjectRows(): void {
const marker = this.localDb
.prepare("SELECT value FROM __meta WHERE key = 'pluginCentralMigrationV1'")
.get() as { value: string } | undefined;
if (marker?.value === "done") return;
const hasPluginsTable = this.localDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'plugins'")
.get() as { name?: string } | undefined;
if (!hasPluginsTable?.name) {
this.localDb
.prepare("INSERT INTO __meta (key, value) VALUES ('pluginCentralMigrationV1', 'done') ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run();
return;
}
const rows = this.localDb
.prepare("SELECT * FROM plugins ORDER BY updatedAt ASC")
.all() as LegacyPluginRow[];
this.centralDb.transaction(() => {
for (const row of rows) {
const existingInstall = this.centralDb
.prepare("SELECT * FROM plugin_installs WHERE id = ?")
.get(row.id) as InstallRow | undefined;
const takeLegacy = !existingInstall || new Date(row.updatedAt).getTime() >= new Date(existingInstall.updatedAt).getTime();
if (takeLegacy) {
this.centralDb
.prepare(`
INSERT INTO plugin_installs (
id, name, version, description, author, homepage, path,
settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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,
settingsSchema = excluded.settingsSchema,
dependencies = excluded.dependencies,
aiScanOnLoad = excluded.aiScanOnLoad,
lastSecurityScan = excluded.lastSecurityScan,
updatedAt = excluded.updatedAt
`)
.run(
row.id,
row.name,
row.version,
row.description,
row.author,
row.homepage,
row.path,
row.settings ?? "{}",
row.settingsSchema,
row.dependencies ?? "[]",
row.aiScanOnLoad === 1 ? 1 : 0,
row.lastSecurityScan ?? null,
existingInstall?.createdAt ?? row.createdAt,
row.updatedAt,
);
}
this.centralDb
.prepare(`
INSERT INTO project_plugin_states (projectPath, pluginId, enabled, state, error, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(projectPath, pluginId) DO UPDATE SET
enabled = excluded.enabled,
state = excluded.state,
error = excluded.error,
updatedAt = excluded.updatedAt
`)
.run(
this.normalizedProjectPath,
row.id,
row.enabled === 1 ? 1 : 0,
row.state,
row.error,
row.createdAt,
row.updatedAt,
);
}
this.localDb
.prepare("INSERT INTO __meta (key, value) VALUES ('pluginCentralMigrationV1', 'done') ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run();
});
}
/**
* Register a new plugin.
*/
async registerPlugin(input: PluginRegistrationInput): Promise<PluginInstallation> {
const { manifest, path, settings = {}, aiScanOnLoad = false } = input;
// Validate manifest
const manifestValidation = validatePluginManifest(manifest);
if (!manifestValidation.valid) {
throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`);
}
// Validate required fields
if (!path?.trim()) {
throw new Error("Plugin path is required and cannot be empty");
}
// Validate id format
if (!this.validateIdFormat(manifest.id)) {
throw new Error(
"Plugin id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)",
);
}
// Check for duplicate
const existing = this.db
.prepare("SELECT id FROM plugins WHERE id = ?")
const existing = this.centralDb
.prepare("SELECT id FROM plugin_installs WHERE id = ?")
.get(manifest.id);
if (existing) {
throw Object.assign(new Error(`Plugin "${manifest.id}" is already registered`), {
@@ -222,7 +376,6 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
});
}
// Compute defaults from settingsSchema and merge with provided settings
const defaultSettings: Record<string, unknown> = {};
if (manifest.settingsSchema) {
for (const [key, schema] of Object.entries(manifest.settingsSchema)) {
@@ -234,159 +387,106 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
const mergedSettings = { ...defaultSettings, ...settings };
const now = new Date().toISOString();
const plugin: PluginInstallation = {
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
author: manifest.author,
homepage: manifest.homepage,
path: path.trim(),
enabled: true,
state: "installed",
settings: mergedSettings,
settingsSchema: manifest.settingsSchema,
dependencies: manifest.dependencies || [],
aiScanOnLoad,
createdAt: now,
updatedAt: now,
};
// Insert into database
this.db.prepare(`
INSERT INTO plugins (
this.centralDb
.prepare(`
INSERT INTO plugin_installs (
id, name, version, description, author, homepage, path,
enabled, state, settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
plugin.id,
plugin.name,
plugin.version,
plugin.description ?? null,
plugin.author ?? null,
plugin.homepage ?? null,
plugin.path,
plugin.enabled ? 1 : 0,
plugin.state,
toJson(plugin.settings),
plugin.settingsSchema ? toJson(plugin.settingsSchema) : null,
toJson(plugin.dependencies),
plugin.aiScanOnLoad ? 1 : 0,
null,
plugin.createdAt,
plugin.updatedAt,
);
settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
.run(
manifest.id,
manifest.name,
manifest.version,
manifest.description ?? null,
manifest.author ?? null,
manifest.homepage ?? null,
path.trim(),
toJson(mergedSettings),
manifest.settingsSchema ? toJson(manifest.settingsSchema) : null,
toJson(manifest.dependencies || []),
aiScanOnLoad ? 1 : 0,
null,
now,
now,
);
this.db.bumpLastModified();
this.upsertProjectState(manifest.id, { enabled: true, state: "installed", error: null });
this.centralDb.bumpLastModified();
const plugin = await this.getPlugin(manifest.id);
this.emit("plugin:registered", plugin);
return plugin;
}
/**
* Unregister (delete) a plugin.
*/
async unregisterPlugin(id: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
this.db.prepare("DELETE FROM plugins WHERE id = ?").run(id);
this.db.bumpLastModified();
this.centralDb.prepare("DELETE FROM plugin_installs WHERE id = ?").run(id);
this.centralDb.bumpLastModified();
this.emit("plugin:unregistered", plugin);
return plugin;
}
/**
* Get a plugin by id.
*/
async getPlugin(id: string): Promise<PluginInstallation> {
const row = this.db.prepare("SELECT * FROM plugins WHERE id = ?").get(id) as unknown as PluginRow | undefined;
if (!row) {
const install = this.centralDb
.prepare("SELECT * FROM plugin_installs WHERE id = ?")
.get(id) as InstallRow | undefined;
if (!install) {
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
}
return this.rowToPlugin(row);
return this.rowToPlugin(install, this.getProjectState(id));
}
/**
* List all plugins, optionally filtered.
*/
async listPlugins(
filter?: { enabled?: boolean; state?: PluginState },
): Promise<PluginInstallation[]> {
let sql = "SELECT * FROM plugins";
const conditions: string[] = [];
const params: (string | number)[] = [];
async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise<PluginInstallation[]> {
const installs = this.centralDb
.prepare("SELECT * FROM plugin_installs ORDER BY createdAt ASC")
.all() as InstallRow[];
if (filter?.enabled !== undefined) {
conditions.push("enabled = ?");
params.push(filter.enabled ? 1 : 0);
}
if (filter?.state) {
conditions.push("state = ?");
params.push(filter.state);
}
const results = installs.map((install) => this.rowToPlugin(install, this.getProjectState(install.id)));
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
sql += " ORDER BY createdAt ASC";
const rows = this.db.prepare(sql).all(...params) as unknown as PluginRow[];
return rows.map((row) => this.rowToPlugin(row));
return results.filter((plugin) => {
if (filter?.enabled !== undefined && plugin.enabled !== filter.enabled) {
return false;
}
if (filter?.state && plugin.state !== filter.state) {
return false;
}
return true;
});
}
/**
* Enable a plugin.
*/
async enablePlugin(id: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
await this.getPlugin(id);
this.upsertProjectState(id, { enabled: true });
this.centralDb.bumpLastModified();
this.db.prepare("UPDATE plugins SET enabled = 1, updatedAt = ? WHERE id = ?").run(
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
const updated = { ...plugin, enabled: true };
const updated = await this.getPlugin(id);
this.emit("plugin:enabled", updated);
this.emit("plugin:updated", updated);
return updated;
}
/**
* Disable a plugin.
*/
async disablePlugin(id: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
await this.getPlugin(id);
this.upsertProjectState(id, { enabled: false });
this.centralDb.bumpLastModified();
this.db.prepare("UPDATE plugins SET enabled = 0, updatedAt = ? WHERE id = ?").run(
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
const updated = { ...plugin, enabled: false };
const updated = await this.getPlugin(id);
this.emit("plugin:disabled", updated);
this.emit("plugin:updated", updated);
return updated;
}
/**
* Update plugin state.
*/
async updatePluginState(
id: string,
state: PluginState,
error?: string,
): Promise<PluginInstallation> {
async updatePluginState(id: string, state: PluginState, error?: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
const oldState = plugin.state;
// Validate state transitions
const validStates: PluginState[] = ["installed", "started", "stopped", "error"];
if (!validStates.includes(state)) {
throw new Error(`Invalid state: ${state}`);
}
// Validate transitions (any state can go to error)
if (state !== "error") {
const validTransitions: Record<PluginState, PluginState[]> = {
installed: ["started", "stopped", "error"],
@@ -395,71 +495,45 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
error: ["installed", "started", "stopped"],
};
if (!validTransitions[oldState]?.includes(state)) {
throw new Error(
`Invalid state transition from "${oldState}" to "${state}"`,
);
throw new Error(`Invalid state transition from "${oldState}" to "${state}"`);
}
}
this.db.prepare("UPDATE plugins SET state = ?, error = ?, updatedAt = ? WHERE id = ?").run(
state,
error ?? null,
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
this.upsertProjectState(id, { state, error: error ?? null });
this.centralDb.bumpLastModified();
const updated = { ...plugin, state, error };
const updated = await this.getPlugin(id);
this.emit("plugin:stateChanged", updated, oldState, state);
this.emit("plugin:updated", updated);
return updated;
}
/**
* Update plugin settings.
*/
async updatePluginSettings(
id: string,
settings: Record<string, unknown>,
): Promise<PluginInstallation> {
async updatePluginSettings(id: string, settings: Record<string, unknown>): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
// Validate settings against schema
const validationErrors = this.validateSettingsAgainstSchema(
settings,
plugin.settingsSchema,
);
const validationErrors = this.validateSettingsAgainstSchema(settings, plugin.settingsSchema);
if (validationErrors.length > 0) {
throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`);
}
// Merge settings
const mergedSettings = { ...plugin.settings, ...settings };
this.db.prepare("UPDATE plugins SET settings = ?, updatedAt = ? WHERE id = ?").run(
toJson(mergedSettings),
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
this.centralDb
.prepare("UPDATE plugin_installs SET settings = ?, updatedAt = ? WHERE id = ?")
.run(toJson(mergedSettings), new Date().toISOString(), id);
this.centralDb.bumpLastModified();
const updated = { ...plugin, settings: mergedSettings };
const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated);
return updated;
}
/**
* Generic update for plugin metadata.
*/
async updatePlugin(
id: string,
updates: PluginUpdateInput,
): Promise<PluginInstallation> {
async updatePlugin(id: string, updates: PluginUpdateInput): Promise<PluginInstallation> {
await this.getPlugin(id);
const now = new Date().toISOString();
const setClauses: string[] = ["updatedAt = ?"];
const params: (string | null)[] = [now];
const params: (string | null | number)[] = [now];
if (updates.name !== undefined) {
setClauses.push("name = ?");
@@ -491,7 +565,7 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
}
if (updates.aiScanOnLoad !== undefined) {
setClauses.push("aiScanOnLoad = ?");
params.push(updates.aiScanOnLoad ? "1" : "0");
params.push(updates.aiScanOnLoad ? 1 : 0);
}
if (updates.lastSecurityScan !== undefined) {
setClauses.push("lastSecurityScan = ?");
@@ -499,8 +573,8 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
}
params.push(id);
this.db.prepare(`UPDATE plugins SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
this.db.bumpLastModified();
this.centralDb.prepare(`UPDATE plugin_installs SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
this.centralDb.bumpLastModified();
const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated);

View File

@@ -23,8 +23,9 @@ import { subscribeSse } from "../sse-bus";
/** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */
interface PluginLifecyclePayload {
scope: "global" | "project";
pluginId: string;
transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated";
transition: "installing" | "enabled" | "disabled" | "error" | "state-changed" | "uninstalled" | "settings-updated";
sourceEvent: string;
timestamp: string;
projectId?: string;
@@ -266,9 +267,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try {
const payload: PluginLifecyclePayload = JSON.parse(e.data);
// Filter by projectId if in project-scoped mode
if (projectId && payload.projectId && payload.projectId !== projectId) {
return;
if (payload.scope === "project") {
if ((payload.projectId ?? projectId) !== projectId) {
return;
}
}
switch (payload.transition) {
@@ -298,6 +300,22 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
});
break;
case "state-changed":
setPlugins((prev) => {
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
if (existingIndex >= 0) {
const updated = [...prev];
updated[existingIndex] = {
...updated[existingIndex],
state: payload.state,
error: payload.error,
};
return updated;
}
return prev;
});
break;
case "uninstalled":
// Remove plugin from list
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
@@ -344,7 +362,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try {
setInstalling(true);
await installPlugin({ path: installPath, ...(installAiScanOnLoad ? { aiScanOnLoad: true } : {}) }, projectId);
addToast("Plugin installed successfully", "success");
addToast("Plugin installed globally", "success");
setShowInstall(false);
setInstallPath("");
setInstallAiScanOnLoad(false);
@@ -365,7 +383,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try {
setInstallingBuiltinPluginId(plugin.id);
await installPlugin({ path: plugin.path }, projectId);
addToast(`${plugin.name} installed successfully`, "success");
addToast(`${plugin.name} installed globally`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to install ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -397,7 +415,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleEnable = async (plugin: PluginInstallation) => {
try {
await enablePlugin(plugin.id, projectId);
addToast(`${plugin.name} enabled`, "success");
addToast(`${plugin.name} enabled for this project`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to enable plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -407,7 +425,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleDisable = async (plugin: PluginInstallation) => {
try {
await disablePlugin(plugin.id, projectId);
addToast(`${plugin.name} disabled`, "success");
addToast(`${plugin.name} disabled for this project`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to disable plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -429,8 +447,8 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleUninstall = async (plugin: PluginInstallation) => {
const shouldUninstall = await confirm({
title: "Uninstall Plugin",
message: `Are you sure you want to uninstall "${plugin.name}"?`,
title: "Uninstall Plugin Globally",
message: `Are you sure you want to uninstall "${plugin.name}" globally (all projects)?`,
danger: true,
});
if (!shouldUninstall) {
@@ -439,7 +457,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try {
await uninstallPlugin(plugin.id, projectId);
addToast(`${plugin.name} uninstalled`, "success");
addToast(`${plugin.name} uninstalled globally`, "success");
await loadPlugins();
setSelectedPlugin(null);
} catch (err) {
@@ -752,15 +770,15 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)}
{selectedPlugin.enabled ? (
<button className="btn btn-secondary" onClick={() => handleDisable(selectedPlugin)}>
Disable
Disable in Project
</button>
) : (
<button className="btn btn-primary" onClick={() => handleEnable(selectedPlugin)}>
Enable
Enable in Project
</button>
)}
<button className="btn btn-danger" onClick={() => handleUninstall(selectedPlugin)}>
<Trash2 size={14} /> Uninstall
<Trash2 size={14} /> Uninstall Globally
</button>
</div>
</div>
@@ -920,7 +938,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</label>
<div className="plugin-install-actions">
<button className="btn btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}>
{installing ? "Installing..." : "Install Plugin"}
{installing ? "Installing..." : "Install Plugin Globally"}
</button>
<button className="btn btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}>
Cancel
@@ -979,7 +997,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<button
className="btn-icon"
onClick={() => handleUninstall(plugin)}
title="Uninstall"
title="Uninstall globally"
>
<Trash2 size={14} />
</button>

View File

@@ -284,7 +284,7 @@ describe("PluginManager browse-driven install workflow", () => {
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Plugin installed successfully",
"Plugin installed globally",
"success",
);
});

View File

@@ -401,7 +401,7 @@ describe("PluginManager", () => {
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
expect(addToast).toHaveBeenCalledWith("Hermes Runtime installed successfully", "success");
expect(addToast).toHaveBeenCalledWith("Hermes Runtime installed globally", "success");
});
});
@@ -421,7 +421,7 @@ describe("PluginManager", () => {
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-dependency-graph" }, undefined);
expect(addToast).toHaveBeenCalledWith("Dependency Graph installed successfully", "success");
expect(addToast).toHaveBeenCalledWith("Dependency Graph installed globally", "success");
});
});
@@ -571,8 +571,8 @@ describe("PluginManager", () => {
await userEvent.click(uninstallButtons[0]);
expect(mockConfirm).toHaveBeenCalledWith({
title: "Uninstall Plugin",
message: 'Are you sure you want to uninstall "Test Plugin A"?',
title: "Uninstall Plugin Globally",
message: 'Are you sure you want to uninstall "Test Plugin A" globally (all projects)?',
danger: true,
});
expect(uninstallPlugin).not.toHaveBeenCalled();

View File

@@ -204,6 +204,7 @@ export type PluginLifecycleTransition =
| "enabled"
| "disabled"
| "error"
| "state-changed"
| "uninstalled"
| "settings-updated";
@@ -219,6 +220,8 @@ export type MessageSseEventType =
* This is the stable contract the UI can reconcile.
*/
export interface PluginLifecyclePayload {
/** Global install metadata event vs project runtime-state event */
scope: "global" | "project";
/** Plugin identifier */
pluginId: string;
/** Normalized transition type */
@@ -261,13 +264,10 @@ function mapSourceEventToTransition(
return "disabled";
case "plugin:stateChanged":
// If the new state is "error", emit the "error" transition
if (plugin.state === "error") {
return "error";
}
// For other state changes (started, stopped), we don't emit a dedicated transition
// but still emit the lifecycle event for observability
return "error"; // Map to "error" as a fallback for non-standard state transitions
return "state-changed";
case "plugin:unregistered":
return "uninstalled";
@@ -291,12 +291,15 @@ function createPluginLifecyclePayload(
plugin: PluginInstallation,
projectId?: string,
): PluginLifecyclePayload {
const transition = mapSourceEventToTransition(sourceEvent, plugin);
const scope = transition === "installing" || transition === "uninstalled" ? "global" : "project";
return {
scope,
pluginId: plugin.id,
transition: mapSourceEventToTransition(sourceEvent, plugin),
transition,
sourceEvent,
timestamp: new Date().toISOString(),
projectId,
projectId: scope === "project" ? projectId : undefined,
enabled: plugin.enabled,
state: plugin.state,
version: plugin.version,

View File

@@ -74,7 +74,7 @@ describe("Droid runtime E2E pipeline", () => {
});
it("loads Droid plugin and creates sessions through Droid runtime without createFnAgent", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
await pluginStore.registerPlugin({
@@ -133,7 +133,7 @@ describe("Droid runtime E2E pipeline", () => {
});
it("falls back to default pi runtime when Droid plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot);

View File

@@ -105,7 +105,7 @@ describe("Hermes runtime E2E pipeline", () => {
});
it("loads Hermes plugin and executes through Hermes runtime without createFnAgent", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
await pluginStore.registerPlugin({
@@ -170,7 +170,7 @@ describe("Hermes runtime E2E pipeline", () => {
});
it("reuses Hermes adapter instance without compatibility wrapping when runtime is AgentRuntime-shaped", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
await pluginStore.registerPlugin({
@@ -218,7 +218,7 @@ describe("Hermes runtime E2E pipeline", () => {
});
it("falls back to default pi runtime when Hermes plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot);
@@ -253,7 +253,7 @@ describe("Hermes runtime E2E pipeline", () => {
// attach the resolved runtime's promptWithFallback onto the session object.
// Without the fix, pi.promptWithFallback (pi.ts:175) would fall through to
// pi's own session.prompt() instead of dispatching to HermesRuntimeAdapter.
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
await pluginStore.registerPlugin({

View File

@@ -153,7 +153,7 @@ describe("OpenClaw runtime E2E pipeline", () => {
});
it("loads OpenClaw plugin and executes through OpenClaw runtime", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
await pluginStore.registerPlugin({
@@ -240,7 +240,7 @@ describe("OpenClaw runtime E2E pipeline", () => {
});
it("falls back to default pi runtime when OpenClaw plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot);

View File

@@ -125,7 +125,7 @@ describe("Paperclip runtime E2E pipeline", () => {
});
it("loads Paperclip plugin and executes through Paperclip runtime", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
await pluginStore.registerPlugin({
@@ -187,7 +187,7 @@ describe("Paperclip runtime E2E pipeline", () => {
});
it("falls back to default pi runtime when Paperclip plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot);