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>
160 lines
5.6 KiB
TypeScript
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,
|
|
};
|
|
}
|