Files
fusion/packages/core/src/plugins/plugin-activation-analytics.ts
gsxdsm 52facd1461 FN-9000: scope runtime records to their projects
Scope runtime data access to the owning project while preserving explicit unbound compatibility.

- Thread optional project identifiers through project-table reads and mutations.
- Isolate chat, approvals, artifacts, secrets, audit, branch groups, plugin analytics, and verification cache records.
- Add PostgreSQL coverage and a patch changeset documenting multi-project isolation.

Files changed:
 .../fn-9000-project-ownership-runtime-scope.md     |   7 +
 docs/storage.md                                    |   3 +-
 .../project-ownership-runtime-scope.pg.test.ts     | 155 +++++++++++++++++++++
 packages/core/src/agents/approval-request-store.ts |  20 ++-
 .../async-stores/async-approval-request-store.ts   |  30 ++--
 packages/core/src/async-stores/async-chat-store.ts | 105 +++++++++++---
 .../core/src/async-stores/async-secrets-store.ts   |  58 +++++---
 packages/core/src/chat/chat-store.ts               |  22 +--
 .../src/plugins/plugin-activation-analytics.ts     |  12 +-
 packages/core/src/secrets/secrets-store.ts         |  12 +-
 packages/core/src/store.ts                         |   6 +-
 .../src/task-store/async/async-archive-lineage.ts  |  16 ++-
 packages/core/src/task-store/async/async-audit.ts  |  18 ++-
 .../src/task-store/async/async-branch-groups.ts    |  50 ++++---
 .../task-store/async/async-comments-attachments.ts |  35 +++--
 .../core/src/task-store/branch-and-pr-entities.ts  |  15 +-
 packages/core/src/task-store/branch-group-ops.ts   |   2 +-
 packages/core/src/task-store/task-artifacts-ops.ts |   2 +-
 packages/core/src/task-store/task-id-integrity.ts  |   2 +-
 packages/core/src/task-store/task-mutation-ops.ts  |  14 +-
 .../core/src/task-store/workflow-definitions.ts    |   7 +
 packages/dashboard/src/server.ts                   |   2 +-
 22 files changed, 472 insertions(+), 121 deletions(-)

Fusion-Task-Id: FN-9000

Fusion-Task-Lineage: 9842e9e9-ec50-4459-8534-dfdaa2cdf35e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-12 07:43:29 -07:00

160 lines
5.6 KiB
TypeScript

import type { Database } from "../db/db.js";
import { projectScopeFor, type AsyncDataLayer } from "../postgres/data-layer.js";
import { and, gte, lte, sql } from "drizzle-orm";
import * as schema from "../postgres/schema/index.js";
/**
* Plugin activation analytics over the project-scoped `plugin_activations` table.
*
* Fusion records one row when a plugin or workflow extension genuinely activates
* through `PluginLoader.loadPlugin` or `reloadPlugin`. The Command Center
* Ecosystem card may show a count only when at least one in-range row exists.
* Empty ranges return `unavailable: true` and `activations: 0` as a transport
* shape, but UI callers must keep the honest unavailable sentinel — never render
* `0` as if missing historical capture meant zero activations.
*
* Inclusivity: `from`/`to` bounds are inclusive and filter `activatedAt`.
*
* FNXC:CommandCenterEcosystem 2026-06-19-08:05:
* Plugin activation analytics are project-scoped event aggregates. Absence of rows means the metric is unavailable for the selected range, not that Fusion observed zero activations.
*/
export interface PluginActivationAnalyticsQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
}
/** Activation count for a single plugin id. */
export interface PluginActivationPluginCount {
pluginId: string;
count: number;
}
export interface PluginActivationAnalytics {
from: string | null;
to: string | null;
/** Real activation rows in range. */
activations: number;
/** Activation rows grouped by plugin id, descending by count. */
byPlugin: PluginActivationPluginCount[];
/** True when no in-range activation rows exist; UI should render the sentinel, not 0. */
unavailable: boolean;
}
interface CountRow {
count: number;
}
interface PluginCountRow {
pluginId: string;
count: number;
}
function rangeWhere(query: PluginActivationAnalyticsQuery): { where: string; params: string[] } {
const clauses: string[] = [];
const params: string[] = [];
if (query.from !== undefined) {
clauses.push("activatedAt >= ?");
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push("activatedAt <= ?");
params.push(query.to);
}
return {
where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
params,
};
}
/**
* Aggregate plugin activations over a date range.
*
* Empty range yields `{ activations: 0, byPlugin: [], unavailable: true }` so
* callers can preserve the Command Center unavailable sentinel rather than
* fabricating a zero-valued metric.
*
* FNXC:CommandCenterEcosystem 2026-06-24-13:10:
* Backend dual-path: when an `AsyncDataLayer` is provided, queries run against
* PostgreSQL via Drizzle. When absent, the legacy sync SQLite path runs.
*/
export async function aggregatePluginActivations(
dbOrLayer: Database | AsyncDataLayer,
query: PluginActivationAnalyticsQuery = {},
): Promise<PluginActivationAnalytics> {
// FNXC:RuntimeSatelliteAsync 2026-06-24-13:10:
// Backend mode: query the PostgreSQL plugin_activations table via Drizzle.
// FNXC:MonitorStoreDiscriminator 2026-06-26-10:30:
// P1 fix (review #17): use `"ping" in dbOrLayer` (unique to AsyncDataLayer)
// instead of the broken `"execute" in dbOrLayer || ("transactionImmediate" in dbOrLayer)`.
if ("ping" in dbOrLayer) {
const layer = dbOrLayer as AsyncDataLayer;
/*
FNXC:ProjectSchemaOwnership 2026-08-12-14:14:
Ecosystem analytics may share one PostgreSQL schema across projects. Scope both
aggregate reads to the bound layer so another project's activation history is
never presented as this dashboard's metric; unbound analytics remain global.
*/
const conditions = [projectScopeFor(schema.project.pluginActivations.projectId, layer.projectId)].filter(
(condition): condition is NonNullable<typeof condition> => condition !== undefined,
);
if (query.from !== undefined) conditions.push(gte(schema.project.pluginActivations.activatedAt, query.from));
if (query.to !== undefined) conditions.push(lte(schema.project.pluginActivations.activatedAt, query.to));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const countRows = await layer.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.project.pluginActivations)
.where(where);
const activations = countRows[0]?.count ?? 0;
const byPluginRows = await layer.db
.select({
pluginId: schema.project.pluginActivations.pluginId,
count: sql<number>`count(*)::int`,
})
.from(schema.project.pluginActivations)
.where(where)
.groupBy(schema.project.pluginActivations.pluginId)
.orderBy(sql`count(*) DESC`, schema.project.pluginActivations.pluginId);
const byPlugin = byPluginRows.map((row) => ({ pluginId: row.pluginId, count: row.count }));
return {
from: query.from ?? null,
to: query.to ?? null,
activations,
byPlugin,
unavailable: activations === 0,
};
}
// Legacy sync SQLite path
const db = dbOrLayer as Database;
const { where, params } = rangeWhere(query);
const activations = (
db
.prepare(`SELECT COUNT(*) AS count FROM plugin_activations ${where}`)
.get(...params) as CountRow
).count;
const byPlugin = db
.prepare(
`SELECT pluginId, COUNT(*) AS count
FROM plugin_activations ${where}
GROUP BY pluginId
ORDER BY count DESC, pluginId ASC`,
)
.all(...params) as PluginCountRow[];
return {
from: query.from ?? null,
to: query.to ?? null,
activations,
byPlugin,
unavailable: activations === 0,
};
}