Fifth family, not in the four you listed — it was about to sit while the others consolidated. **Six folded; two need arbitration.** ## Folded (cherry-picked clean) migration marker · async archived check · audited-sentinel missing its marker · five of six `archived` checks in one file · the two artifact/comment read-only guards · the last unmarked `getLiveTaskColumn` sentinel. One root cause, which is why they belong together: **a literal compared against a SENTINEL value is not a lifecycle-lane guard** — the census counts it, and the fix is a marker, not a conversion. ## The baseline conflicted on every cherry-pick All six re-recorded `lifecycle-column-census-baseline.json` independently. I resolved by **regenerating once from the folded tree** rather than merging six hand-edits: the baseline is a derived artifact, so the measured value is the only correct resolution, and hand-merging derived JSON is how a wrong ceiling gets locked in. That is the strongest case for the family model I can give you: six PRs touching one derived file conflict pairwise regardless of merge order — 15 possible pairs — and auto-rebase would have churned them serially. ## NOT folded — one line for arbitration **#2925 (`live-task-column-lanes`) conflicts with #2923 (`fix/task-id-integrity-sentinel`) on `packages/core/src/task-store/task-id-integrity.ts`.** #2923 marks a sentinel there; #2925 converts lanes. Different intents, same file. I did not guess which wins — land one, rebase the other, fold both after. ## Verification `--strict` exit 0 · backlog **158**, reviewed **122** · core typecheck clean · scoped, not full suite. ## Queue **52 → 39** after my two folds (this + #2940 portal). The ~24 "self-healing … on a renamed board" family is still the dominant block. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Clarified lifecycle-state terminology and migration markers throughout task and project management documentation. * Documented the distinction between archived-task sentinels and workflow column identifiers. * Updated lifecycle documentation tracking to reflect the latest coverage. * **Bug Fixes** * No runtime behavior changes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
620 lines
22 KiB
TypeScript
620 lines
22 KiB
TypeScript
/**
|
|
* SQLite-backed PluginStore for managing plugin installations.
|
|
*
|
|
* 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, resolve } from "node:path";
|
|
import { Database, fromJson } from "./db.js";
|
|
import { CentralDatabase } from "./central-db.js";
|
|
import type {
|
|
PluginInstallation,
|
|
PluginManifest,
|
|
PluginSecurityScanResult,
|
|
PluginSettingSchema,
|
|
PluginState,
|
|
} from "./plugin-types.js";
|
|
import { validatePluginManifest } from "./plugin-types.js";
|
|
import { assertProjectRootDir } from "./project-root-guard.js";
|
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:00:
|
|
* Async Drizzle helpers for backend-mode (PostgreSQL) PluginStore operations.
|
|
* These helpers target the central-schema tables via Drizzle and are the async
|
|
* equivalent of the sync centralDb/localDb.prepare() call sites below.
|
|
*/
|
|
import {
|
|
registerPlugin as registerPluginAsync,
|
|
unregisterPlugin as unregisterPluginAsync,
|
|
getPlugin as getPluginAsync,
|
|
listPlugins as listPluginsAsync,
|
|
enablePlugin as enablePluginAsync,
|
|
disablePlugin as disablePluginAsync,
|
|
updatePluginState as updatePluginStateAsync,
|
|
updatePluginSettings as updatePluginSettingsAsync,
|
|
updatePluginInstall as updatePluginInstallAsync,
|
|
} from "./async-plugin-store.js";
|
|
|
|
export interface PluginStoreEvents {
|
|
"plugin:registered": [plugin: PluginInstallation];
|
|
"plugin:unregistered": [plugin: PluginInstallation];
|
|
"plugin:enabled": [plugin: PluginInstallation];
|
|
"plugin:disabled": [plugin: PluginInstallation];
|
|
"plugin:updated": [plugin: PluginInstallation];
|
|
"plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState];
|
|
}
|
|
|
|
export interface PluginRegistrationInput {
|
|
manifest: PluginManifest;
|
|
path: string;
|
|
settings?: Record<string, unknown>;
|
|
aiScanOnLoad?: boolean;
|
|
}
|
|
|
|
export interface PluginUpdateInput {
|
|
name?: string;
|
|
version?: string;
|
|
description?: string;
|
|
author?: string;
|
|
homepage?: string;
|
|
path?: string;
|
|
dependencies?: string[];
|
|
settingsSchema?: Record<string, PluginSettingSchema> | null;
|
|
aiScanOnLoad?: boolean;
|
|
lastSecurityScan?: PluginSecurityScanResult;
|
|
}
|
|
|
|
interface LegacyPluginRow {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
description: string | null;
|
|
author: string | null;
|
|
homepage: string | null;
|
|
path: string;
|
|
enabled: number;
|
|
state: string;
|
|
settings: string | null;
|
|
settingsSchema: string | null;
|
|
error: string | null;
|
|
dependencies: string | null;
|
|
aiScanOnLoad?: number;
|
|
lastSecurityScan?: string | null;
|
|
createdAt: string;
|
|
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 interface PluginStoreOptions {
|
|
centralGlobalDir?: string;
|
|
/**
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:05:
|
|
* When an AsyncDataLayer is injected, PluginStore operates in "backend mode":
|
|
* all data access delegates to PostgreSQL via Drizzle and no SQLite
|
|
* Database is constructed. When absent, the legacy SQLite path is
|
|
* byte-identical to pre-migration. This mirrors the TaskStore/AgentStore
|
|
* dual-path pattern.
|
|
*/
|
|
asyncLayer?: AsyncDataLayer;
|
|
}
|
|
|
|
export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
|
private _localDb: Database | null = null;
|
|
private _centralDb: CentralDatabase | null = null;
|
|
private readonly normalizedProjectPath: string;
|
|
private readonly centralGlobalDir?: string;
|
|
|
|
/**
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:05:
|
|
* When set, PluginStore operates in backend mode (PostgreSQL via Drizzle).
|
|
* All data access delegates to async helpers. No SQLite Database is
|
|
* constructed. This mirrors the TaskStore/AgentStore dual-path pattern.
|
|
*/
|
|
public readonly asyncLayer: AsyncDataLayer | null = null;
|
|
|
|
/** True when AsyncDataLayer was injected. Gates all SQLite construction. */
|
|
public get backendMode(): boolean {
|
|
return this.asyncLayer !== null;
|
|
}
|
|
|
|
constructor(
|
|
private rootDir: string,
|
|
options?: PluginStoreOptions,
|
|
) {
|
|
super();
|
|
assertProjectRootDir(rootDir, "PluginStore");
|
|
this.normalizedProjectPath = resolve(rootDir);
|
|
this.centralGlobalDir = options?.centralGlobalDir;
|
|
this.asyncLayer = options?.asyncLayer ?? null;
|
|
}
|
|
|
|
private get localDb(): Database {
|
|
if (this.backendMode) {
|
|
throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)");
|
|
}
|
|
if (!this._localDb) {
|
|
const fusionDir = join(this.rootDir, ".fusion");
|
|
this._localDb = new Database(fusionDir);
|
|
this._localDb.init();
|
|
}
|
|
return this._localDb;
|
|
}
|
|
|
|
private get centralDb(): CentralDatabase {
|
|
if (this.backendMode) {
|
|
throw new Error("CentralDatabase is not available in backend mode (asyncLayer injected)");
|
|
}
|
|
if (!this._centralDb) {
|
|
this._centralDb = new CentralDatabase(this.centralGlobalDir);
|
|
this._centralDb.init();
|
|
}
|
|
return this._centralDb;
|
|
}
|
|
|
|
/**
|
|
* FNXC:Plugins 2026-06-25-03:31:
|
|
* Shared test harnesses clear the file-backed global settings directory between tests while reusing one TaskStore.
|
|
* Dispose both plugin database handles first so future plugin access reopens a fresh central DB instead of writing through a connection whose backing file was removed.
|
|
*/
|
|
close(): void {
|
|
this._localDb?.close();
|
|
this._localDb = null;
|
|
this._centralDb?.close();
|
|
this._centralDb = null;
|
|
}
|
|
|
|
/**
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:10:
|
|
* 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<void> {
|
|
/*
|
|
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
|
|
PluginStore.init is a no-op under PostgreSQL. The startup factory completes the retained-SQLite bridge with its privileged migration connection before constructing the runtime layer; do not open SQLite local/central DBs here.
|
|
FNXC:PluginLegacyMigration 2026-07-15-02:09:
|
|
PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states.
|
|
*/
|
|
}
|
|
|
|
private validateIdFormat(id: string): boolean {
|
|
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id);
|
|
}
|
|
|
|
private validateSettingsAgainstSchema(
|
|
settings: Record<string, unknown>,
|
|
schema?: Record<string, PluginSettingSchema>,
|
|
): string[] {
|
|
if (!schema) return [];
|
|
|
|
const errors: string[] = [];
|
|
for (const [key, settingSchema] of Object.entries(schema)) {
|
|
const value = settings[key];
|
|
if (settingSchema.required && !(key in settings)) {
|
|
errors.push(`Setting "${key}" is required`);
|
|
continue;
|
|
}
|
|
if (!(key in settings)) continue;
|
|
|
|
const expectedType = settingSchema.type;
|
|
if (expectedType === "string" && typeof value !== "string") {
|
|
errors.push(`Setting "${key}" must be a string`);
|
|
} else if (expectedType === "password" && typeof value !== "string") {
|
|
errors.push(`Setting "${key}" must be a string`);
|
|
} else if (expectedType === "number" && typeof value !== "number") {
|
|
errors.push(`Setting "${key}" must be a number`);
|
|
} else if (expectedType === "boolean" && typeof value !== "boolean") {
|
|
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(", ")}`);
|
|
}
|
|
} else if (expectedType === "array") {
|
|
if (!Array.isArray(value)) {
|
|
errors.push(`Setting "${key}" must be an array`);
|
|
} else {
|
|
const itemType = settingSchema.itemType;
|
|
for (const item of value) {
|
|
if (itemType === "string" && typeof item !== "string") {
|
|
errors.push(`Setting "${key}" must be an array of string`);
|
|
break;
|
|
} else if (itemType === "number" && typeof item !== "number") {
|
|
errors.push(`Setting "${key}" must be an array of number`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
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;
|
|
/*
|
|
FNXC:LifecycleColumnCensus 2026-07-30-23:59 DELIBERATE-LITERAL: a MIGRATION MARKER, not a lane.
|
|
|
|
The lifecycle-column census counts `=== "done"` comparisons, and this one is a `__meta` key/value
|
|
row recording whether `pluginCentralMigrationV1` has run — the same vocabulary the two writes below
|
|
use. It has nothing to do with a board column, and converting it to a role read would compare a
|
|
migration flag against a workflow's complete lane, which is meaningless and would break the
|
|
migration on any board that renames `done`.
|
|
|
|
Marked rather than left counted because an entry in the backlog is a claim that a conversion is
|
|
OWED here, and the next person to work the list would spend the time discovering it is not.
|
|
*/
|
|
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();
|
|
}
|
|
|
|
async registerPlugin(input: PluginRegistrationInput): Promise<PluginInstallation> {
|
|
const { manifest, path, settings = {}, aiScanOnLoad = false } = input;
|
|
|
|
const manifestValidation = validatePluginManifest(manifest);
|
|
if (!manifestValidation.valid) {
|
|
throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`);
|
|
}
|
|
|
|
if (!path?.trim()) {
|
|
throw new Error("Plugin path is required and cannot be empty");
|
|
}
|
|
|
|
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)",
|
|
);
|
|
}
|
|
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
|
|
* Backend-mode: delegate to the async Drizzle registerPlugin helper which
|
|
* inserts the install row + per-project state atomically via a transaction.
|
|
*/
|
|
const plugin = await registerPluginAsync(this.asyncLayer!, {
|
|
manifest,
|
|
path,
|
|
settings,
|
|
aiScanOnLoad,
|
|
projectPath: this.normalizedProjectPath,
|
|
});
|
|
this.emit("plugin:registered", plugin);
|
|
return plugin;
|
|
}
|
|
|
|
async unregisterPlugin(id: string): Promise<PluginInstallation> {
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
|
|
* Backend-mode: delegate to the async Drizzle unregisterPlugin helper.
|
|
*/
|
|
const plugin = await unregisterPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
|
|
this.emit("plugin:unregistered", plugin);
|
|
return plugin;
|
|
}
|
|
|
|
async getPlugin(id: string): Promise<PluginInstallation> {
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
|
|
* Backend-mode: delegate to the async Drizzle getPlugin helper.
|
|
*/
|
|
return getPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
|
|
}
|
|
|
|
async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise<PluginInstallation[]> {
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
|
|
* Backend-mode: delegate to the async Drizzle listPlugins helper.
|
|
*/
|
|
return listPluginsAsync(this.asyncLayer!.db, this.normalizedProjectPath, filter);
|
|
}
|
|
|
|
async enablePlugin(id: string): Promise<PluginInstallation> {
|
|
/*
|
|
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
|
|
Plugin enable is PostgreSQL-only; SQLite upsertProjectState arm deleted.
|
|
*/
|
|
const updated = await enablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
|
|
this.emit("plugin:enabled", updated);
|
|
this.emit("plugin:updated", updated);
|
|
return updated;
|
|
}
|
|
|
|
async disablePlugin(id: string): Promise<PluginInstallation> {
|
|
/*
|
|
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
|
|
Plugin disable is PostgreSQL-only; SQLite upsertProjectState arm deleted.
|
|
*/
|
|
const updated = await disablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
|
|
this.emit("plugin:disabled", updated);
|
|
this.emit("plugin:updated", updated);
|
|
return updated;
|
|
}
|
|
|
|
async updatePluginState(id: string, state: PluginState, error?: string): Promise<PluginInstallation> {
|
|
const plugin = await this.getPlugin(id);
|
|
const oldState = plugin.state;
|
|
|
|
const validStates: PluginState[] = ["installed", "started", "stopped", "error"];
|
|
if (!validStates.includes(state)) {
|
|
throw new Error(`Invalid state: ${state}`);
|
|
}
|
|
|
|
if (state === oldState) {
|
|
// Same-state transitions are idempotent by design. Only emit plugin:updated
|
|
// when a provided error payload actually changes persisted plugin fields.
|
|
if (error === undefined || plugin.error === error) {
|
|
return plugin;
|
|
}
|
|
|
|
/*
|
|
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
|
|
Plugin state updates are PostgreSQL-only.
|
|
*/
|
|
const updated = await updatePluginStateAsync(
|
|
this.asyncLayer!.db,
|
|
id,
|
|
this.normalizedProjectPath,
|
|
state,
|
|
error,
|
|
);
|
|
this.emit("plugin:updated", updated);
|
|
return updated;
|
|
}
|
|
|
|
if (state !== "error") {
|
|
const validTransitions: Record<PluginState, PluginState[]> = {
|
|
installed: ["started", "stopped", "error"],
|
|
started: ["stopped", "error"],
|
|
stopped: ["started", "error"],
|
|
error: ["installed", "started", "stopped"],
|
|
};
|
|
if (!validTransitions[oldState]?.includes(state)) {
|
|
throw new Error(`Invalid state transition from "${oldState}" to "${state}"`);
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
|
|
Plugin state transitions are PostgreSQL-only; SQLite upsertProjectState arm deleted.
|
|
*/
|
|
const updated = await updatePluginStateAsync(
|
|
this.asyncLayer!.db,
|
|
id,
|
|
this.normalizedProjectPath,
|
|
state,
|
|
error ?? null,
|
|
);
|
|
this.emit("plugin:stateChanged", updated, oldState, state);
|
|
this.emit("plugin:updated", updated);
|
|
return updated;
|
|
}
|
|
|
|
async updatePluginSettings(id: string, settings: Record<string, unknown>): Promise<PluginInstallation> {
|
|
const plugin = await this.getPlugin(id);
|
|
|
|
const validationErrors = this.validateSettingsAgainstSchema(settings, plugin.settingsSchema);
|
|
if (validationErrors.length > 0) {
|
|
throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`);
|
|
}
|
|
|
|
const mergedSettings = { ...plugin.settings, ...settings };
|
|
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:25:
|
|
* Backend-mode: delegate settings persistence to the async helper.
|
|
*/
|
|
await updatePluginSettingsAsync(this.asyncLayer!.db, id, mergedSettings);
|
|
const updated = await this.getPlugin(id);
|
|
this.emit("plugin:updated", updated);
|
|
return updated;
|
|
}
|
|
|
|
async updatePlugin(id: string, updates: PluginUpdateInput): Promise<PluginInstallation> {
|
|
await this.getPlugin(id);
|
|
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:25:
|
|
* Backend-mode: delegate install-field persistence to the async helper.
|
|
*/
|
|
await updatePluginInstallAsync(this.asyncLayer!.db, id, {
|
|
name: updates.name,
|
|
version: updates.version,
|
|
description: updates.description,
|
|
author: updates.author,
|
|
homepage: updates.homepage,
|
|
path: updates.path,
|
|
dependencies: updates.dependencies,
|
|
aiScanOnLoad: updates.aiScanOnLoad,
|
|
lastSecurityScan: updates.lastSecurityScan,
|
|
});
|
|
const updated = await this.getPlugin(id);
|
|
this.emit("plugin:updated", updated);
|
|
return updated;
|
|
}
|
|
}
|