Files
fusion/packages/core/src/tool-analytics.ts
gsxdsm c25f8b796d Harden PostgreSQL migration foundation (#2088)
## Summary

- make SQLite-to-PostgreSQL cutover retryable, fail-closed, versioned,
and transactionally serialized
- isolate migration sessions from runtime traffic and apply schema
upgrades through `0002`
- enforce tenant ownership across automations, analytics, activity,
usage, agent runs, evals, and todos
- replace expired SQLite-only coverage with PostgreSQL parity and
concurrency coverage

This is PR 1 of 2. The stacked follow-up restores PostgreSQL parity for
CLI, engine, dashboard, and bundled integrations.

## Verification

- `pnpm check:changesets --strict`
- `pnpm --filter @fusion/core typecheck`
- migration schema, connection, and SQLite cutover suite: 57 tests
passed
- `pnpm test:gate`: 463 tests passed

## Post-Deploy Monitoring & Validation

- take a restorable PostgreSQL backup before deploy
- confirm `fusion_schema_migrations` contains `0002`
- confirm each expected project has a complete
`fusion_sqlite_migrations` row
- verify no null or empty tenant ownership in automations, activity
logs, agent runs, and usage events
- monitor for ownership inference failures, cutover verification
failures, and migration session errors
- restore the backup for data rollback; do not downgrade the
tenant-isolation schema in place

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* PostgreSQL-backed analytics and live dashboard metrics are now
project-scoped (activity, tools, monitor, signals, and live snapshots).
* Evaluation runs and scheduled eval batches received lifecycle
improvements (ordering, updates, and execution flow).
* Todo list changes now emit events; WhatsApp persistence and
project-scoped roadmap data are supported.

* **Bug Fixes**
* SQLite-to-PostgreSQL cutovers now fail safely with stronger
verification, serialized cutover handling, and safer project ownership.
* PostgreSQL backend writes and reads are now strictly project-isolated
and fail closed when project context is missing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 08:16:42 -07:00

339 lines
13 KiB
TypeScript

import { sql } from "drizzle-orm";
import type { Database } from "./db.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import { categorizeToolName } from "./usage-events.js";
import type { SteeringComment } from "./types.js";
/**
* Tool-usage analytics over `usage_events`, plus the **autonomy ratio**.
*
* Autonomy ratio = tool_call count / human-intervention count. The denominator
* is NOT raw user messages (which trend to zero for autonomous execution); it is
* the count of human interventions, which has **three distinct sources** — they
* are not one queryable table:
*
* 1. **Approvals** — rows in `approval_request_audit_events` whose `eventType`
* is `created` or `approved` (a human was asked to / did approve an action),
* timestamped by `createdAt`.
* 2. **User-authored steers** — entries in the `steeringComments` JSON column
* on the `tasks` row, filtered to `author === "user"` (agent-authored steers
* are excluded), timestamped by each comment's `createdAt`.
* 3. **Waiting-on-input** — a task *status*, not a counted event; intentionally
* DROPPED here (no concrete answer event is defined).
*
* A fully-autonomous session (zero interventions) must not divide by zero or
* report ∞: when `interventions === 0` the ratio falls back to
* tool-calls-per-session (`toolCalls / max(sessions, 1)`), and the result flags
* `interventions: 0` so callers can render it as "fully autonomous".
*
* Inclusivity: `from`/`to` bounds are inclusive, matching `usage-events.ts`.
*/
export interface ToolAnalyticsQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
}
/** Tool-call count for a single coarse category. */
export interface ToolCategoryCount {
category: string;
count: number;
}
/** Breakdown of the autonomy-ratio denominator by source. */
export interface InterventionBreakdown {
/** `created`/`approved` rows in `approval_request_audit_events`. */
approvals: number;
/** `steeringComments` entries with `author === "user"`. */
userSteers: number;
/** Total human interventions (sum of the components above). */
total: number;
}
export interface ToolAnalytics {
from: string | null;
to: string | null;
/** Total `tool_call` events in range. */
toolCalls: number;
/** Tool calls grouped by `category`, descending by count. */
byCategory: ToolCategoryCount[];
/** Distinct sessions (`session_start` events) in range. */
sessions: number;
interventions: InterventionBreakdown;
/**
* Autonomy ratio. When `interventions.total > 0` this is
* `toolCalls / interventions.total`. When there are zero interventions it is
* tool-calls-per-session (`toolCalls / max(sessions, 1)`) and
* `fullyAutonomous` is true — never ∞ or NaN.
*/
autonomyRatio: number;
/** True when zero human interventions were recorded in range. */
fullyAutonomous: boolean;
}
interface CountRow {
count: number;
}
interface CategoryRow {
toolName: string | null;
category: string | null;
count: number;
}
interface SteeringRow {
steeringComments: string | null;
}
function inRange(ts: string, from?: string, to?: string): boolean {
if (from !== undefined && ts < from) return false;
if (to !== undefined && ts > to) return false;
return true;
}
/**
* Count human interventions from the three named sources (waiting-on-input is a
* status, not counted). Returns the per-source breakdown plus the total.
*/
export async function countInterventions(
dbOrLayer: Database | AsyncDataLayer,
query: ToolAnalyticsQuery = {},
): Promise<InterventionBreakdown> {
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
// Backend (PostgreSQL) path. approval_request_audit_events.event_type /
// created_at and tasks.steering_comments (jsonb, already parsed by
// postgres-js) are read from schema-qualified project.* tables; the
// user-authored-steer in-range counting mirrors the sync branch exactly.
if ("ping" in dbOrLayer) {
const layer = dbOrLayer as AsyncDataLayer;
/*
FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49:
An unbound Command Center layer intentionally aggregates every project. Apply the optional project predicate to approval events and task-backed steering reads only when a project is explicitly bound; never reinterpret an absent binding as the empty-string partition.
*/
const projectScope = layer.projectId !== undefined
? sql`AND project_id = ${layer.projectId}`
: sql``;
const aFrom = query.from !== undefined ? sql`AND created_at >= ${query.from}` : sql``;
const aTo = query.to !== undefined ? sql`AND created_at <= ${query.to}` : sql``;
const approvalRows = (await layer.db.execute(
sql`SELECT count(*)::int AS count FROM project.approval_request_audit_events
WHERE event_type IN ('created', 'approved') ${projectScope} ${aFrom} ${aTo}`,
)) as Array<{ count: number }>;
const approvals = approvalRows[0]?.count ?? 0;
const steeringRows = (await layer.db.execute(
sql`SELECT steering_comments AS "steeringComments" FROM project.tasks
WHERE steering_comments IS NOT NULL
${projectScope}
AND jsonb_typeof(steering_comments) = 'array'
AND jsonb_array_length(steering_comments) > 0`,
)) as Array<{ steeringComments: unknown }>;
let userSteers = 0;
for (const row of steeringRows) {
const parsed = row.steeringComments;
if (!Array.isArray(parsed)) continue;
for (const comment of parsed as SteeringComment[]) {
if (comment?.author !== "user") continue;
if (!inRange(comment.createdAt ?? "", query.from, query.to)) continue;
userSteers += 1;
}
}
return { approvals, userSteers, total: approvals + userSteers };
}
const db = dbOrLayer as Database;
// Source 1: approvals. `approval_request_audit_events.createdAt` is the ts;
// count only the human-touch event types.
const approvalClauses: string[] = ["eventType IN ('created', 'approved')"];
const approvalParams: string[] = [];
if (query.from !== undefined) {
approvalClauses.push("createdAt >= ?");
approvalParams.push(query.from);
}
if (query.to !== undefined) {
approvalClauses.push("createdAt <= ?");
approvalParams.push(query.to);
}
const approvals = (
db
.prepare(
`SELECT COUNT(*) AS count FROM approval_request_audit_events WHERE ${approvalClauses.join(" AND ")}`,
)
.get(...approvalParams) as CountRow
).count;
// Source 2: user-authored steers from the `steeringComments` JSON on tasks.
// This re-introduces a per-task JSON read (documented in U2). Only rows with a
// non-empty JSON array are scanned.
const steeringRows = db
.prepare(
`SELECT steeringComments FROM tasks
WHERE steeringComments IS NOT NULL AND steeringComments NOT IN ('', '[]')`,
)
.all() as SteeringRow[];
let userSteers = 0;
for (const row of steeringRows) {
if (!row.steeringComments) continue;
let parsed: SteeringComment[];
try {
parsed = JSON.parse(row.steeringComments) as SteeringComment[];
} catch {
continue;
}
if (!Array.isArray(parsed)) continue;
for (const comment of parsed) {
if (comment?.author !== "user") continue;
if (!inRange(comment.createdAt ?? "", query.from, query.to)) continue;
userSteers += 1;
}
}
return { approvals, userSteers, total: approvals + userSteers };
}
/**
* Aggregate tool usage and the autonomy ratio over a date range.
*
* Empty range yields zeroed structures (not nulls) and `autonomyRatio: 0`.
*/
export async function aggregateToolAnalytics(
dbOrLayer: Database | AsyncDataLayer,
query: ToolAnalyticsQuery = {},
): Promise<ToolAnalytics> {
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
// Backend (PostgreSQL) path. tool_call / session_start counts and the
// tool_name/category group come from schema-qualified project.usage_events
// (snake_case columns; the async connection has no `project` on search_path).
// Category re-derivation, autonomy-ratio, and the zero-intervention fallback
// run via the shared buildToolAnalytics, identical to the sync branch.
if ("ping" in dbOrLayer) {
const layer = dbOrLayer as AsyncDataLayer;
/*
FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49:
Tool-call totals, category groups, and session counts must share the same optional project scope. Unbound reads aggregate all partitions; explicitly bound reads remain isolated.
*/
const projectScope = layer.projectId !== undefined
? sql`AND project_id = ${layer.projectId}`
: sql``;
const eFrom = query.from !== undefined ? sql`AND ts >= ${query.from}` : sql``;
const eTo = query.to !== undefined ? sql`AND ts <= ${query.to}` : sql``;
const toolCallsRows = (await layer.db.execute(
sql`SELECT count(*)::int AS count FROM project.usage_events
WHERE kind = 'tool_call' ${projectScope} ${eFrom} ${eTo}`,
)) as Array<{ count: number }>;
const toolCalls = toolCallsRows[0]?.count ?? 0;
const categoryRows = (await layer.db.execute(
sql`SELECT tool_name AS "toolName", category AS category, count(*)::int AS count
FROM project.usage_events
WHERE kind = 'tool_call' ${projectScope} ${eFrom} ${eTo}
GROUP BY tool_name, category`,
)) as unknown as CategoryRow[];
const sessionsRows = (await layer.db.execute(
sql`SELECT count(*)::int AS count FROM project.usage_events
WHERE kind = 'session_start' ${projectScope} ${eFrom} ${eTo}`,
)) as Array<{ count: number }>;
const sessions = sessionsRows[0]?.count ?? 0;
const interventions = await countInterventions(layer, query);
return buildToolAnalytics(toolCalls, categoryRows, sessions, interventions, query);
}
const db = dbOrLayer as Database;
const eventClauses: string[] = [];
const eventParams: string[] = [];
if (query.from !== undefined) {
eventClauses.push("ts >= ?");
eventParams.push(query.from);
}
if (query.to !== undefined) {
eventClauses.push("ts <= ?");
eventParams.push(query.to);
}
const rangeWhere = eventClauses.length > 0 ? `AND ${eventClauses.join(" AND ")}` : "";
const toolCalls = (
db
.prepare(
`SELECT COUNT(*) AS count FROM usage_events WHERE kind = 'tool_call' ${rangeWhere}`,
)
.get(...eventParams) as CountRow
).count;
/**
* FNXC:CommandCenter 2026-06-17-21:43:
* Historical usage rows were logged with `category = "other"` before Fusion tool families were mapped, so aggregation must re-derive those buckets from `toolName`.
* Preserve explicit non-`other` categories because external callers may already provide a deliberate custom bucket.
*/
const categoryRows = db
.prepare(
`SELECT toolName AS toolName, category AS category, COUNT(*) AS count
FROM usage_events
WHERE kind = 'tool_call' ${rangeWhere}
GROUP BY toolName, category`,
)
.all(...eventParams) as CategoryRow[];
const sessions = (
db
.prepare(
`SELECT COUNT(*) AS count FROM usage_events WHERE kind = 'session_start' ${rangeWhere}`,
)
.get(...eventParams) as CountRow
).count;
const interventions = await countInterventions(db, query);
return buildToolAnalytics(toolCalls, categoryRows, sessions, interventions, query);
}
/**
* FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
* Pure assembly shared by the sync (SQLite) and async (PostgreSQL) paths of
* {@link aggregateToolAnalytics}. Re-derives `other` categories from toolName,
* sorts the category breakdown, and computes the autonomy ratio with the
* zero-intervention tool-calls-per-session fallback. No I/O.
*/
function buildToolAnalytics(
toolCalls: number,
categoryRows: CategoryRow[],
sessions: number,
interventions: InterventionBreakdown,
query: ToolAnalyticsQuery,
): ToolAnalytics {
const categoryCounts = new Map<string, number>();
for (const row of categoryRows) {
const category = row.category && row.category !== "other" ? row.category : categorizeToolName(row.toolName);
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + row.count);
}
const byCategory: ToolCategoryCount[] = [...categoryCounts.entries()]
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category));
let autonomyRatio: number;
let fullyAutonomous: boolean;
if (interventions.total > 0) {
autonomyRatio = toolCalls / interventions.total;
fullyAutonomous = false;
} else {
// Zero interventions: report tool-calls-per-session, never ∞ / divide-by-zero.
autonomyRatio = toolCalls / Math.max(sessions, 1);
fullyAutonomous = true;
}
return {
from: query.from ?? null,
to: query.to ?? null,
toolCalls,
byCategory,
sessions,
interventions,
autonomyRatio,
fullyAutonomous,
};
}