diff --git a/.changeset/config.json b/.changeset/config.json index 89ddb43a41..0f9ca557b7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -35,7 +35,6 @@ "@corbits/icons", "@corbits/inbox", "@corbits/inference-settings", - "@corbits/insights", "@corbits/jimmy-agent", "@corbits/longevity-sim", "@corbits/memory-hub", diff --git a/apps/hub/package.json b/apps/hub/package.json index f900543976..ea2a17c050 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -36,7 +36,7 @@ "@corbits/granola-call-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inbox": "workspace:*", - "@corbits/insights": "workspace:*", + "@corbits/inference-catalog": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/mcp-tools": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b254351dbe..f2ae18a777 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -93,17 +93,6 @@ import { createInboxRoutes, WORKBENCH_MAILBOX_VOCABULARY, } from "@corbits/inbox"; -import { - applyInsightsMigrations, - createDrizzleRunTraceReader, - createDrizzleTurnTextSnapshotReader, - createInsightsRoutes, - createPostgresTurnLatencyStore, - createPostgresUsageStore, - createTurnLatencyTracker, - createUsageSink, - withTurnPartPersistGuard, -} from "@corbits/insights"; import { generateId } from "@intx/hub-common"; import { @@ -244,6 +233,7 @@ import { } from "./config"; import type { SidecarProvisioner } from "@intx/hub-sessions"; import { withTurnPartWriteDefaults } from "./turn-part-content-default"; +import { createDrizzleTurnTextSnapshotReader } from "./turn-text-snapshot"; import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory"; import { runNowScheduledDefinition, @@ -715,34 +705,8 @@ export async function createHub(config: HubConfig) { }, ) => void; } = {}; - // Package-owned insights tables, migrated ahead of the event collector - // registry so `usageSink` is live before the first `inference.usage` - // event can arrive. - await applyInsightsMigrations(config.databaseUrl); - const insightsUsage = createPostgresUsageStore(config.databaseUrl); - const insightsLatency = createPostgresTurnLatencyStore(config.databaseUrl); - const usageSink = createUsageSink({ - store: insightsUsage.store, - generateId: () => generateId("inferenceTurn"), - }); - // CL-6257: per-message-run stage latency (message-received → - // reactor.start → inference.start → first-token → reply-posted). The - // vendored event collector never persists the events this reads (see - // @corbits/insights' latency-tracker.ts header) and isn't ours to edit, - // so this observes the same InferenceEvent stream from outside it by - // wrapping `eventCollectors` below — the same seam `withTurnPartPersistGuard` - // already uses on the `db` handle passed into the vendored registry. - const turnLatency = createTurnLatencyTracker({ - store: insightsLatency.store, - generateId: () => generateId("inferenceTurn"), - }); const baseEventCollectors = createEventCollectorRegistry({ - // `withTurnPartPersistGuard` (see @corbits/insights) wraps - // `withTurnPartWriteDefaults`: it retries a turn_part insert once on - // the collector's known turn_id/session_id FK race and makes any - // surviving loss loud (error-level cause, counted) instead of a - // swallowed WRN. - db: withTurnPartPersistGuard(withTurnPartWriteDefaults(db)), + db: withTurnPartWriteDefaults(db), // CL-7418: deliberately no terminal-status settle here. The folded // routine-fire settle (CL-6778, keyed off routines.routine_run) died // with the routines cut (CL-4455) and has no native equivalent: a @@ -757,59 +721,22 @@ export async function createHub(config: HubConfig) { onTurnFinalized: (agentAddress, turn) => { artifactDeliveryHandlerRef.current?.(agentAddress, turn); }, - // Per-turn usage, emitted once when the collector finalizes a turn. - onUsage: (_agentAddress, usage) => { - void usageSink - .handle({ - turnId: usage.turnId, - tenantId: usage.tenantId, - sessionId: usage.sessionId, - provider: usage.provider, - model: usage.model, - tokens: usage.usage, - }) - .catch((err: unknown) => { - log.warn`Failed to record usage for turn ${usage.turnId}: ${err instanceof Error ? err.message : String(err)}`; - }); - }, }); - // Wraps every `EventCollectorRegistry` call the vendored session - // orchestrator makes: `create`/`dispatch`/`abandon` also feed - // `turnLatency`, which is the only place tenantId/sessionId land - // against an agentAddress for the raw event stream (the registry keeps - // that mapping private). Every other method passes straight through. + // Wraps `dispatch` for CL-7480: a run's turn events can start arriving + // before anything else ever recorded its session — the first inbound + // trigger that just reconciled its principal races this same dispatch. + // No live collector for the address is exactly that case: ensure the + // session (and, inside it, the collector) before delegating, rather + // than silently dropping the event for a collector that never gets + // created. Every other method passes straight through. const eventCollectors: EventCollectorRegistry = { ...baseEventCollectors, - create(agentAddress, tenantId, sessionId, runId) { - turnLatency.onSessionCreate(agentAddress, tenantId, sessionId); - baseEventCollectors.create(agentAddress, tenantId, sessionId, runId); - }, dispatch(agentAddress, event) { - // CL-7480: a run's turn events can start arriving before anything - // else ever recorded its session — the first inbound trigger that - // just reconciled its principal races this same dispatch. No live - // collector for the address is exactly that case: ensure the - // session (and, inside it, the collector) before delegating, - // rather than silently dropping the event for a collector that - // never gets created. if (!baseEventCollectors.has(agentAddress)) { void ensureCollectorThenDispatch(agentAddress, event); return; } - turnLatency.onEvent(agentAddress, event); baseEventCollectors.dispatch(agentAddress, event); - // Mirrors the registry's own `isTerminal` check (event-collector-registry.ts) - // so `turnLatency`'s per-agentAddress session map is cleared on the - // same terminal events that make the registry drop its own collector - // — otherwise a session that ends without `abandon()` never frees. - const isTerminal = - event.type === "reactor.done" || - (event.type === "reactor.error" && event.data.fatal); - if (isTerminal) turnLatency.onSessionEnd(agentAddress); - }, - abandon(agentAddress) { - turnLatency.onSessionEnd(agentAddress); - baseEventCollectors.abandon(agentAddress); }, }; eventCollectorsRef.current = eventCollectors; @@ -835,12 +762,7 @@ export async function createHub(config: HubConfig) { extra: { agentAddress }, }); } - turnLatency.onEvent(agentAddress, event); baseEventCollectors.dispatch(agentAddress, event); - const isTerminal = - event.type === "reactor.done" || - (event.type === "reactor.error" && event.data.fatal); - if (isTerminal) turnLatency.onSessionEnd(agentAddress); } createHubSessionOrchestrator({ events: sidecarRouter.events, @@ -1626,33 +1548,6 @@ export async function createHub(config: HubConfig) { `${TENANT_PREFIX}/inbox`, createInboxRoutes({ db: mailboxDb, bus: mailboxBus }), ); - // Insights usage sink + read API. Package-owned tables are migrated - // at hub start (idempotent ledger); the store is Postgres-backed so - // numbers survive restarts. Absent rates / pre-sink history stay null. - // runTraceReader reads the platform's own workflow_run / - // inference_turn / turn_part tables directly - // (see @corbits/insights' createDrizzleRunTraceReader) — no new storage, - // same `db` handle every other platform-table reader in this file uses. - // The sink itself is constructed earlier, alongside `eventCollectors` - // (see the `onUsage` hook on `createEventCollectorRegistry` above), - // which reports each finalized turn's usage with its run identity. - app.route( - `${TENANT_PREFIX}/insights`, - createInsightsRoutes({ - store: insightsUsage.store, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - runTraceReader: createDrizzleRunTraceReader(db), - latencyStore: insightsLatency.store, - // Same `db` handle every other platform-table reader in this file - // uses — lets /usage, /activity, /tools, and /scope roll up a - // workspace parent's child chats (see resolveScope in - // @corbits/insights' routes.ts). - db, - }), - ); // A workflow definition's own detail page (CL-7371): what it is, // whether it can run right now, its steps, and its access surface. // Mounted alongside — not inside — the vendored @@ -2287,8 +2182,6 @@ export async function createHub(config: HubConfig) { chatOrchestrator.dispose(); inboxUnsnoozeSweep.stop(); cronEmitter.stop(); - await insightsUsage.close(); - await insightsLatency.close(); await closeMailbox(); // The pool end waits on in-flight queries; a query whose socket // died with the process must never stall shutdown, so bound it. diff --git a/packages/insights/src/turn-text-snapshot.test.ts b/apps/hub/src/turn-text-snapshot.test.ts similarity index 100% rename from packages/insights/src/turn-text-snapshot.test.ts rename to apps/hub/src/turn-text-snapshot.test.ts diff --git a/packages/insights/src/turn-text-snapshot.ts b/apps/hub/src/turn-text-snapshot.ts similarity index 100% rename from packages/insights/src/turn-text-snapshot.ts rename to apps/hub/src/turn-text-snapshot.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0869ad3c47..37af7651f7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,7 +30,6 @@ "@corbits/error-sink": "workspace:*", "@corbits/inbox": "workspace:*", "@corbits/inference-settings": "workspace:*", - "@corbits/insights": "workspace:*", "@corbits/plugins-ui": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", diff --git a/apps/web/src/insights-api.test.ts b/apps/web/src/insights-api.test.ts index 5905d3622e..924009e428 100644 --- a/apps/web/src/insights-api.test.ts +++ b/apps/web/src/insights-api.test.ts @@ -1,49 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { - createInsightsWindow, - INSIGHTS_WINDOW_DAYS, -} from "@corbits/insights/client"; - -import { - insightsActivityPath, - insightsToolsPath, - insightsTopLevelRunsPath, - insightsUsagePath, -} from "./insights-api"; - -// Fixed clock so range math is deterministic regardless of suite time. -const NOW = new Date("2026-01-15T18:00:00.000Z"); - -describe("insights path range contract", () => { - test("usage, activity, and tools share the same from/to", () => { - const range = createInsightsWindow(INSIGHTS_WINDOW_DAYS, NOW); - const usage = insightsUsagePath("tenant-1", range); - const activity = insightsActivityPath("tenant-1", range); - const tools = insightsToolsPath("tenant-1", range); - - for (const path of [usage, activity, tools]) { - const url = new URL(path, "http://local"); - expect(url.searchParams.get("from")).toBe(range.from); - expect(url.searchParams.get("to")).toBe(range.to); - } - - expect(usage.startsWith("/api/tenants/tenant-1/insights/usage?")).toBe( - true, - ); - expect( - activity.startsWith("/api/tenants/tenant-1/insights/activity?"), - ).toBe(true); - expect(tools.startsWith("/api/tenants/tenant-1/insights/tools?")).toBe( - true, - ); - }); -}); +import { insightsTopLevelRunsPath } from "./insights-api"; describe("insightsTopLevelRunsPath", () => { // CL-8087: the native tenant-scoped top-level listing, not the deleted // `/top-level-runs` route (or its `feed=fires` variant) and not the dead - // `/me/workflows/runs`. + // `/me/workflows/runs`. CL-8160 deleted `packages/insights` and its + // usage/activity/tools/latency/scope routes entirely. test("hits the native tenant-scoped GET /workflows/runs listing", () => { const path = insightsTopLevelRunsPath("tenant-1"); expect(path.startsWith("/api/tenants/tenant-1/workflows/runs?")).toBe(true); diff --git a/apps/web/src/insights-api.ts b/apps/web/src/insights-api.ts index 97e3ecd8bf..5b70191b6f 100644 --- a/apps/web/src/insights-api.ts +++ b/apps/web/src/insights-api.ts @@ -1,230 +1,18 @@ -// This app's fetch/query plumbing over packages/insights routes: arktype -// schemas that parse every trust boundary, and the path builders for this -// app's tenant-scoped insights routes. Pure domain formatting/windowing -// (formatUsd, activitySeriesForWindow, EMPTY_OVERALL_USAGE, etc.) lives in -// @corbits/insights/client — browser-safe and shared with any UI over this -// data, not tied to this app's routes. -// GET /usage → OverallUsageSummary -// GET /activity → { days: DayActivity[] } -// GET /tools → { tools: ToolCallSummary[] } -// GET /runs/:runId/trace → RunTrace | { runId, spans: null, absent } -// GET /scope → InsightsScope — own identity, parent (if any), sibling -// workbenches. usage/activity/tools roll up automatically when called -// with a parent's tenantId (see @corbits/insights' resolveScope) — -// /scope is how a page discovers that shape to build a switcher. -// Plus one more reused as Insights' run feed: the native tenant-scoped -// `GET /workflows/runs` top-level listing → Paginated -// (CL-8087 deleted `@corbits/run-scope`'s `/top-level-runs?feed=fires`, -// which used to attribute each fire to its routine server-side). +// This app's fetch/query plumbing over the native tenant-scoped +// `GET /workflows/runs` top-level listing (CL-8087) → +// Paginated. CL-8087 deleted `@corbits/run-scope`'s +// `/top-level-runs?feed=fires`, which used to attribute each fire to its +// routine server-side; CL-8160 deleted `packages/insights` itself — the +// hub mounts nothing Workbench-specific any more. The four stock +// observability routes (`vendor/intx/hub-api/src/routes/observability.ts`) +// are unimplemented stubs (each returns 501), so usage/cost/activity, +// tool-call, latency, run-trace, and cross-workbench scope all lost their +// only data source and are dropped rather than faked — see the CL-8160 PR +// description for the full accounting. import { type } from "arktype"; import { WorkflowRunResponse, paginatedSchema } from "@intx/types"; -import type { InsightsRange } from "@corbits/insights/client"; - -export const TokenTotalsSchema = type({ - input: "number", - cacheRead: "number", - cacheWrite: "number", - output: "number", - thinking: "number", - total: "number", -}); - -export const ModelUsageSchema = type({ - model: "string", - turns: "number", - tokens: TokenTotalsSchema, - costUsd: "number | null", -}); - -/** GET /usage body — OverallUsageSummary from packages/insights. */ -export const OverallUsageSchema = type({ - turns: "number", - tokens: TokenTotalsSchema, - costUsd: "number | null", - byModel: ModelUsageSchema.array(), -}); - -export const ModelDayUsageSchema = type({ - model: "string", - tokens: "number", - costUsd: "number | null", -}); - -export const DayActivitySchema = type({ - day: "string", - turns: "number", - tokens: "number", - byModel: ModelDayUsageSchema.array(), -}); - -/** GET /activity envelope. */ -export const ActivityResponseSchema = type({ - days: DayActivitySchema.array(), -}); - -/** One workbench's usage totals — GET /workbenches item. */ -export const WorkbenchUsageSchema = type({ - tenantId: "string", - name: "string", - turns: "number", - tokens: TokenTotalsSchema, - costUsd: "number | null", -}); - -/** GET /workbenches envelope. */ -export const WorkbenchesResponseSchema = type({ - items: WorkbenchUsageSchema.array(), -}); - -export type WorkbenchUsage = typeof WorkbenchUsageSchema.infer; - -export function insightsWorkbenchesPath( - tenantId: string, - range: InsightsRange, -): string { - return withInsightsRange( - `/api/tenants/${tenantId}/insights/workbenches`, - range, - ); -} - -/** p50/p95 (ms) over one turn stage, or null samples/values when that - * stage recorded nothing in range (see @corbits/insights' LatencyStageStat). */ -export const LatencyStageStatSchema = type({ - p50Ms: "number | null", - p95Ms: "number | null", - samples: "number", -}); - -/** GET /latency body — LatencySummary from packages/insights. */ -export const LatencySummarySchema = type({ - toReactorStart: LatencyStageStatSchema, - toInferenceStart: LatencyStageStatSchema, - toFirstToken: LatencyStageStatSchema, - toReplyPosted: LatencyStageStatSchema, - total: LatencyStageStatSchema, -}); - -export type LatencySummary = typeof LatencySummarySchema.infer; - -export function insightsLatencyPath( - tenantId: string, - range: InsightsRange, -): string { - return withInsightsRange(`/api/tenants/${tenantId}/insights/latency`, range); -} - -export const ToolCallSchema = type({ - tool: "string", - calls: "number", - errors: "number", - errorRate: "number | null", -}); - -/** GET /tools envelope. */ -export const ToolsResponseSchema = type({ - tools: ToolCallSchema.array(), -}); - -export const RunTraceSpanSchema = type({ - id: "string", - label: "string", - kind: "string", - start: "number", - end: "number", - durationMs: "number | null", - tokens: type({ - input: "number", - cacheRead: "number", - cacheWrite: "number", - output: "number", - thinking: "number", - }).or(type("null")), - phase: "'ok' | 'awaiting' | 'failed'", - error: "string | null", - timingSource: "'measured' | 'ordinal'", -}); - -/** Present run trace (reader mounted and run found). */ -export const RunTracePresentSchema = type({ - runId: "string", - spans: RunTraceSpanSchema.array(), -}); - -/** Explicit absent when no run-trace reader is mounted. */ -export const RunTraceAbsentSchema = type({ - runId: "string", - spans: "null", - absent: "string", -}); - -export const RunTraceSchema = RunTracePresentSchema.or(RunTraceAbsentSchema); - -export type ActivityResponse = typeof ActivityResponseSchema.infer; -export type ToolCall = typeof ToolCallSchema.infer; -export type ToolsResponse = typeof ToolsResponseSchema.infer; -export type RunTrace = typeof RunTraceSchema.infer; -export type RunTraceSpan = typeof RunTraceSpanSchema.infer; - -function withInsightsRange(path: string, range: InsightsRange): string { - const params = new URLSearchParams({ - from: range.from, - to: range.to, - }); - return `${path}?${params.toString()}`; -} - -export function insightsUsagePath( - tenantId: string, - range: InsightsRange, -): string { - return withInsightsRange(`/api/tenants/${tenantId}/insights/usage`, range); -} - -export function insightsActivityPath( - tenantId: string, - range: InsightsRange, -): string { - return withInsightsRange(`/api/tenants/${tenantId}/insights/activity`, range); -} - -export function insightsToolsPath( - tenantId: string, - range: InsightsRange, -): string { - return withInsightsRange(`/api/tenants/${tenantId}/insights/tools`, range); -} - -export function insightsRunTracePath(tenantId: string, runId: string): string { - return `/api/tenants/${tenantId}/insights/runs/${encodeURIComponent(runId)}/trace`; -} - -export const InsightsScopeTenantSchema = type({ - tenantId: "string", - name: "string", -}); - -/** - * GET /scope body — the current workbench's own identity, its parent (a - * workspace, when this workbench was created under one — null for a - * root workbench with no parent), and the sibling workbenches to switch - * between (just itself when there is no parent). - */ -export const InsightsScopeSchema = type({ - tenantId: "string", - name: "string", - parent: InsightsScopeTenantSchema.or(type("null")), - workbenches: InsightsScopeTenantSchema.array(), -}); - -export type InsightsScope = typeof InsightsScopeSchema.infer; - -export function insightsScopePath(tenantId: string): string { - return `/api/tenants/${tenantId}/insights/scope`; -} - export const ListingTurnSchema = type({ status: "string", "endedAt?": "string | null", @@ -276,14 +64,15 @@ const TOP_LEVEL_RUNS_LIMIT = 100; * included — `computeInsightsStats` counts it as deployed rather than * hiding it; and the feed is single-tenant — the deleted route expanded * the requested tenant to its whole descendant subtree via - * `getDescendantTenants` (the same rollup `resolveScope` still gives - * `/usage`, `/activity`, and `/tools`), while the native listing filters - * `tenantId = requested tenant` only, so a workspace parent's runs feed - * no longer rolls up its child workbenches and now sits mismatched - * against its own usage aggregate. Used in place of the dead - * `/me/workflows/runs` — its `anchorRunId IS NULL` filter never matches, - * because every addressed run self-anchors at creation, so that feed - * always came back empty. + * `getDescendantTenants` (the same rollup `resolveScope` used to give + * `/usage`, `/activity`, and `/tools` before CL-8160 deleted them), while + * the native listing filters `tenantId = requested tenant` only, so a + * workspace parent's runs feed no longer rolls up its child workbenches + * and now sits mismatched against its own usage aggregate (moot now that + * usage itself is gone). Used in place of the dead `/me/workflows/runs` + * — its `anchorRunId IS NULL` filter never matched anything, because + * every addressed run self-anchors at creation, so that feed always came + * back empty. */ export function insightsTopLevelRunsPath(tenantId: string): string { return `/api/tenants/${tenantId}/workflows/runs?limit=${TOP_LEVEL_RUNS_LIMIT}`; diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index efc0459557..152ee12650 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -4,33 +4,14 @@ import { FIRE_RUNNING_WINDOW_MS } from "@corbits/workflows/client"; import { computeInsightsStats, - computeTraceStats, - filterRunsByCreatedAt, groupRunsByDefinition, INSIGHTS_RECENT_LIMIT, purposeRunsForInsights, runDisplayName, } from "./insights-stats"; -import type { InsightsRun, RunTraceSpan } from "./insights-api"; +import type { InsightsRun } from "./insights-api"; import type { ScheduledWorkflowDefinition } from "./routines-api"; -function span( - partial: Partial & Pick, -): RunTraceSpan { - return { - label: partial.id, - kind: "tool", - start: 0, - end: 1000, - durationMs: null, - tokens: null, - phase: "ok", - error: null, - timingSource: "measured", - ...partial, - }; -} - function run( partial: Partial & Pick, ): InsightsRun { @@ -173,27 +154,6 @@ describe("purposeRunsForInsights", () => { }); }); -describe("computeTraceStats", () => { - test("returns null when spans are absent or empty", () => { - expect(computeTraceStats(null)).toBeNull(); - expect(computeTraceStats([])).toBeNull(); - }); - - test("derives steps, completed, failed, and duration from spans", () => { - const stats = computeTraceStats([ - span({ id: "a", phase: "ok", start: 0, end: 500 }), - span({ id: "b", phase: "failed", start: 200, end: 900 }), - span({ id: "c", phase: "awaiting", start: 400, end: 1200 }), - ]); - expect(stats).toEqual({ - steps: 3, - completed: 1, - failed: 1, - durationMs: 1200, - }); - }); -}); - describe("groupRunsByDefinition", () => { test("groups runs by definitionId, newest first within each group", () => { const groups = groupRunsByDefinition([ @@ -342,51 +302,3 @@ describe("runDisplayName", () => { expect(runDisplayName(native)).toBe("researcher"); }); }); - -describe("filterRunsByCreatedAt", () => { - const from = "2026-01-08T18:00:00.000Z"; - const to = "2026-01-15T18:00:00.000Z"; - - test("keeps runs inside the inclusive window", () => { - const filtered = filterRunsByCreatedAt( - [ - run({ - id: "old", - status: "stopped", - createdAt: "2026-01-08T17:59:59.000Z", - }), - run({ id: "edge-from", status: "stopped", createdAt: from }), - run({ - id: "mid", - status: "running", - createdAt: "2026-01-12T12:00:00.000Z", - }), - run({ id: "edge-to", status: "deployed", createdAt: to }), - run({ - id: "future", - status: "running", - createdAt: "2026-01-15T18:00:01.000Z", - }), - ], - from, - to, - ); - expect(filtered.map((r) => r.id)).toEqual(["edge-from", "mid", "edge-to"]); - }); - - test("drops invalid createdAt timestamps", () => { - const filtered = filterRunsByCreatedAt( - [ - run({ id: "bad", status: "stopped", createdAt: "not-a-date" }), - run({ - id: "ok", - status: "stopped", - createdAt: "2026-01-10T00:00:00.000Z", - }), - ], - from, - to, - ); - expect(filtered.map((r) => r.id)).toEqual(["ok"]); - }); -}); diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index c7e250ec14..72aa8f3799 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -8,39 +8,22 @@ import { withListingAbandoned, } from "@corbits/workflows/client"; -import type { InsightsRun, RunTraceSpan } from "./insights-api"; +import type { InsightsRun } from "./insights-api"; import type { ScheduledWorkflowDefinition } from "./routines-api"; -export type TraceStats = { - readonly steps: number; - readonly completed: number; - readonly failed: number; - readonly durationMs: number; -}; +/** Compact integer; null/undefined → em-dash. Lifted out of the deleted + * `@corbits/insights/client` (CL-8160) — this app's own copy since it has + * no other browser-safe home now that the package is gone. */ +export function formatCount(value: number | null | undefined): string { + if (value === null || value === undefined) return "—"; + return value.toLocaleString(); +} -/** - * Run-detail stat strip (steps/completed/failed/duration) is derived from - * the trace's own spans — never fabricated when the trace is absent or - * empty. Returns null when there is nothing to derive from. - */ -export function computeTraceStats( - spans: readonly RunTraceSpan[] | null, -): TraceStats | null { - if (spans === null || spans.length === 0) return null; - let completed = 0; - let failed = 0; - for (const span of spans) { - if (span.phase === "ok") completed += 1; - if (span.phase === "failed") failed += 1; - } - const start = Math.min(...spans.map((s) => s.start)); - const end = Math.max(...spans.map((s) => s.end)); - return { - steps: spans.length, - completed, - failed, - durationMs: Math.max(0, end - start), - }; +/** "1.2s" / "3.4m" duration label — same lift as `formatCount` above. */ +export function durationLabel(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60_000).toFixed(1)}m`; } export type InsightsStats = { @@ -150,21 +133,6 @@ export function groupRunsByDefinition( ); } -export function filterRunsByCreatedAt( - runs: readonly InsightsRun[], - fromIso: string, - toIso: string, -): readonly InsightsRun[] { - const fromMs = Date.parse(fromIso); - const toMs = Date.parse(toIso); - if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return []; - return runs.filter((run) => { - const t = Date.parse(run.createdAt); - if (Number.isNaN(t)) return false; - return t >= fromMs && t <= toMs; - }); -} - export function computeInsightsStats( runs: readonly InsightsRun[], routines: readonly ScheduledWorkflowDefinition[], diff --git a/apps/web/src/insights-workbench-scope.test.ts b/apps/web/src/insights-workbench-scope.test.ts index cbff66ea48..e7b0de8e0e 100644 --- a/apps/web/src/insights-workbench-scope.test.ts +++ b/apps/web/src/insights-workbench-scope.test.ts @@ -2,10 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { Workbench } from "@corbits/chat-ui"; -import { - workbenchIdForWorkbenchTenant, - resolveWorkbenchInsightsScope, -} from "./insights-workbench-scope"; +import { resolveWorkbenchInsightsScope } from "./insights-workbench-scope"; function workbench(overrides: Partial & { id: string }): Workbench { return { @@ -50,18 +47,3 @@ describe("resolveWorkbenchInsightsScope", () => { }); }); }); - -describe("workbenchIdForWorkbenchTenant", () => { - test("finds the workbench that carries a given workbench tenant", () => { - const workbenches = [ - workbench({ id: "ch_1", tenancy: { tenantId: "tnt_1" } }), - workbench({ id: "ch_2", tenancy: { tenantId: "tnt_2" } }), - ]; - expect(workbenchIdForWorkbenchTenant(workbenches, "tnt_2")).toBe("ch_2"); - }); - - test("returns null when no workbench carries that tenancy", () => { - const workbenches = [workbench({ id: "ch_1", tenancy: null })]; - expect(workbenchIdForWorkbenchTenant(workbenches, "tnt_9")).toBeNull(); - }); -}); diff --git a/apps/web/src/insights-workbench-scope.ts b/apps/web/src/insights-workbench-scope.ts index dc19871f27..461a534d76 100644 --- a/apps/web/src/insights-workbench-scope.ts +++ b/apps/web/src/insights-workbench-scope.ts @@ -2,11 +2,12 @@ // resolves to that workbench's OWN workbench tenant — every workbench minted // through POST /workbenches carries a tenancy link (see `WorkbenchWire.tenancy` // in @corbits/chat-ui) — never the workbench id itself and never the bench's -// root tenant. Both directions of that lookup (a workbench id in the URL -// resolving to a tenant id; a tenant id off a usage row resolving back to -// the workbench that opens it) go through these two pure functions over the -// SAME cached workbench rows the shell's sidebar already fetched, rather -// than a bespoke reverse-lookup endpoint. +// root tenant. That lookup goes through this one pure function over the SAME +// cached workbench rows the shell's sidebar already fetches, rather than a +// bespoke endpoint. CL-8160 deleted the reverse lookup +// (`workbenchIdForWorkbenchTenant`): it only served the cross-workbench +// "activity by workbench" chart and scope switcher, both dropped along with +// packages/insights. import type { Workbench } from "@corbits/chat-ui"; @@ -40,23 +41,3 @@ export function resolveWorkbenchInsightsScope( title: workbench.title, }; } - -/** The reverse lookup: a workbench usage row only carries the workbench's - * tenant id (see `WorkbenchUsage` in `./insights-api`) — this finds the - * workbench that opens it, for the "activity by workbench" rows and the - * scope switcher's sibling pills. Null when no workbench in view carries - * that tenancy (shouldn't happen for a same-bench sibling, but never - * invents a link). */ -export function workbenchIdForWorkbenchTenant( - workbenches: readonly Workbench[], - tenantId: string, -): string | null { - return ( - workbenches.find( - (c) => - c.tenancy !== undefined && - c.tenancy !== null && - c.tenancy.tenantId === tenantId, - )?.id ?? null - ); -} diff --git a/apps/web/src/pages/insights-page-render.test.tsx b/apps/web/src/pages/insights-page-render.test.tsx index 988b488583..92e248a745 100644 --- a/apps/web/src/pages/insights-page-render.test.tsx +++ b/apps/web/src/pages/insights-page-render.test.tsx @@ -1,9 +1,12 @@ -// A malformed percent-escape on an Insights deep link -// (`/insights/workbench/%E0%A4%A`, `/insights/runs/%`) must render the -// same landing dashboard any other unrecognized Insights path gets — never -// a blank page, and never `InsightsWorkbenchPage`/the run-detail route with -// no entity to show (see `insights-path.ts`'s `parseInsightsPath`, which -// InsightsPage calls with the exact same `path` prop this test passes). +// A malformed percent-escape on an Insights deep link (`/insights/runs/%`) +// must render the same landing dashboard any other unrecognized Insights +// path gets — never a blank page (see `insights-path.ts`'s +// `parseInsightsPath`, which InsightsPage calls with the exact same `path` +// prop this test passes). CL-8160 deleted the usage/activity/tools/latency/ +// scope-switcher props `InsightsPage` used to take along with +// `packages/insights` itself — `InsightsWorkbenchPage`'s workbench-scoped +// mode now lives entirely in `InsightsRoute`, so a `/insights/workbench/...` +// path is out of scope for this component-level test. import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; @@ -11,12 +14,8 @@ import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import type { APIQuery } from "@corbits/api-query"; -import { - EMPTY_OVERALL_USAGE, - EMPTY_TOKEN_TOTALS, -} from "@corbits/insights/client"; -import { InsightsPage, useInsightsWindow } from "./insights-page"; +import { InsightsPage } from "./insights-page"; import { BenchContext } from "../bench-context"; import type { BenchState } from "../bench-context"; import type { InsightsRun } from "../insights-api"; @@ -55,23 +54,13 @@ function InsightsPageAtPath({ readonly path: string; readonly runs?: RunsStub; }) { - const range = useInsightsWindow(); return ( {}}> null} - scopeLabel="All workbenches" /> @@ -94,18 +83,11 @@ function render(path: string, runs?: RunsStub) { } describe("InsightsPage with a malformed URL escape", () => { - test("a malformed workbench deep link still renders the landing dashboard", () => { - const el = render("/insights/workbench/%E0%A4%A"); - expect(el.textContent).not.toBe(""); - expect(el.textContent).toContain("Insights"); - expect(el.textContent).toContain("All workbenches"); - }); - test("a malformed run deep link still renders the landing dashboard, not run detail", () => { const el = render("/insights/runs/%"); expect(el.textContent).not.toBe(""); expect(el.textContent).toContain("Insights"); - expect(el.textContent).toContain("All workbenches"); + expect(el.textContent).toContain("Recent runs"); }); }); @@ -138,10 +120,10 @@ describe("InsightsPage 'Running now' strip", () => { expect(el.textContent).toContain("Weekly digest"); }); - // Liveness is not a windowed property: a run that started long before the - // 7-day window and is still running must not disappear from the strip or - // read 0 in the "Running now" KPI just because its start time falls - // outside `range`. Persist has not settled (`endedAt` absent), so the + // Liveness is not a windowed property: a run that started long ago and is + // still running must not disappear from the strip or read 0 in the + // "Running now" KPI just because it started long before this page's + // recent-runs slice. Persist has not settled (`endedAt` absent), so the // fire is live — not remapped to completed by the abandoned-fire window. test("a run started 8 days ago that is still running stays in the strip and the KPI", () => { const eightDaysAgo = new Date( @@ -196,71 +178,3 @@ describe("InsightsPage 'Running now' strip", () => { expect(el.textContent).not.toBe(before); }); }); - -describe("CL-6659 unreported local-model tokens", () => { - test("turns with no token counts label the exclusion instead of Cost $0.00 / 0/0", () => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const range = { - from: "2026-01-08T18:00:00.000Z", - to: "2026-01-15T18:00:00.000Z", - }; - act(() => { - root?.render( - {}}> - - null} - scopeLabel="All workbenches" - /> - - , - ); - }); - expect(container?.textContent).toContain("Token counts were not reported"); - expect(container?.textContent).toContain("qwen3:latest"); - expect(container?.textContent).not.toMatch(/Cost\s*\$0\.00/); - }); -}); diff --git a/apps/web/src/pages/insights-page.test.ts b/apps/web/src/pages/insights-page.test.ts index ac035b9719..ea1b4574a3 100644 --- a/apps/web/src/pages/insights-page.test.ts +++ b/apps/web/src/pages/insights-page.test.ts @@ -1,104 +1,8 @@ -// CL-6099: the landing view's default scope must always be something the -// caller can actually see — resolveInsightsScope is the pure decision -// InsightsRoute defers to, covered here directly against both membership -// shapes `/scope` (packages/insights/src/routes.ts) can now report: a -// workspace member (parent present) and a non-member (parent absent). -// CL-5879 retired the `/insights/workbench/:tenantId` deep link that used -// to override this default outright — per-workbench Insights is now its -// own `/insights/workbench/:workbenchId` route (see `InsightsWorkbenchPage`), -// so this landing scope never takes a workbench override anymore. import { describe, expect, test } from "bun:test"; -import type { DayActivity } from "@corbits/insights/client"; import { FIRE_RUNNING_WINDOW_MS } from "@corbits/workflows/client"; -import { - costPerDay, - elapsedLabel, - isRunningNow, - resolveInsightsScope, - runsPerDay, -} from "./insights-page"; -import type { InsightsRun, InsightsScope } from "../insights-api"; - -const workspaceMemberScope: InsightsScope = { - tenantId: "tnt_bench_a", - name: "Support bench", - parent: { tenantId: "tnt_workspace", name: "Acme workspace" }, - workbenches: [ - { tenantId: "tnt_bench_a", name: "Support bench" }, - { tenantId: "tnt_bench_b", name: "Sales bench" }, - ], -}; - -const nonMemberScope: InsightsScope = { - tenantId: "tnt_bench_a", - name: "Support bench", - parent: null, - workbenches: [{ tenantId: "tnt_bench_a", name: "Support bench" }], -}; - -describe("resolveInsightsScope", () => { - test("workspace member default landing: parent aggregate, labeled 'All workbenches'", () => { - const result = resolveInsightsScope({ - mode: "landing", - selectedTenantId: "tnt_bench_a", - scopeData: workspaceMemberScope, - }); - expect(result.effectiveTenantId).toBe("tnt_workspace"); - expect(result.scopeLabel).toBe("All workbenches"); - }); - - test("parentless landing IS the aggregate: every workbench's runs land on the root tenancy", () => { - const result = resolveInsightsScope({ - mode: "landing", - selectedTenantId: "tnt_bench_a", - scopeData: nonMemberScope, - }); - expect(result.effectiveTenantId).toBe(nonMemberScope.tenantId); - expect(result.scopeLabel).toBe("All workbenches"); - }); - - test("scope not yet resolved: stays on the current workbench id but never labels it with a raw id", () => { - const result = resolveInsightsScope({ - mode: "landing", - selectedTenantId: "tnt_bench_a", - scopeData: null, - }); - expect(result.effectiveTenantId).toBe("tnt_bench_a"); - expect(result.scopeLabel).toBe("All workbenches"); - }); - - test("non-landing modes always stay tied to the current workbench, ignoring scope", () => { - const runsResult = resolveInsightsScope({ - mode: "runs", - selectedTenantId: "tnt_bench_a", - scopeData: workspaceMemberScope, - }); - expect(runsResult.effectiveTenantId).toBe("tnt_bench_a"); - - const runResult = resolveInsightsScope({ - mode: "run", - selectedTenantId: "tnt_bench_a", - scopeData: workspaceMemberScope, - }); - expect(runResult.effectiveTenantId).toBe("tnt_bench_a"); - }); - - test("no selected tenant and unresolved scope: never fabricates a tenant to view", () => { - const result = resolveInsightsScope({ - mode: "landing", - selectedTenantId: null, - scopeData: null, - }); - expect(result.effectiveTenantId).toBeNull(); - }); -}); - -function day( - partial: Partial & Pick, -): DayActivity { - return { turns: 0, tokens: 0, byModel: [], ...partial }; -} +import { elapsedLabel, isRunningNow } from "./insights-page"; +import type { InsightsRun } from "../insights-api"; function run( partial: Partial & Pick, @@ -116,60 +20,6 @@ function run( }; } -describe("runsPerDay", () => { - test("buckets each run's date onto the matching day, zero elsewhere", () => { - const days = [ - day({ day: "2026-01-01" }), - day({ day: "2026-01-02" }), - day({ day: "2026-01-03" }), - ]; - const runs = [ - run({ id: "a", createdAt: "2026-01-01T09:00:00.000Z" }), - run({ id: "b", createdAt: "2026-01-01T18:00:00.000Z" }), - run({ id: "c", createdAt: "2026-01-03T00:00:01.000Z" }), - ]; - expect(runsPerDay(runs, days)).toEqual([2, 0, 1]); - }); - - test("a run outside the window contributes to no bucket", () => { - const days = [day({ day: "2026-01-01" })]; - const runs = [run({ id: "a", createdAt: "2025-12-25T00:00:00.000Z" })]; - expect(runsPerDay(runs, days)).toEqual([0]); - }); - - test("no days: returns an empty series, not a fabricated one", () => { - expect( - runsPerDay([run({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" })], []), - ).toEqual([]); - }); -}); - -describe("costPerDay", () => { - test("sums known per-model costs for each day", () => { - const days = [ - day({ - day: "2026-01-01", - byModel: [ - { model: "opus-5", tokens: 100, costUsd: 1.5 }, - { model: "sonnet-5", tokens: 50, costUsd: 0.25 }, - ], - }), - day({ day: "2026-01-02", byModel: [] }), - ]; - expect(costPerDay(days)).toEqual([1.75, 0]); - }); - - test("a null model rate contributes 0, not NaN — caller decides whether to show it", () => { - const days = [ - day({ - day: "2026-01-01", - byModel: [{ model: "new-model", tokens: 10, costUsd: null }], - }), - ]; - expect(costPerDay(days)).toEqual([0]); - }); -}); - describe("elapsedLabel", () => { test("formats wall-clock time since createdAt", () => { const now = Date.parse("2026-01-01T00:02:12.000Z"); diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 6e936f17a3..f4a2e4afdd 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -1,15 +1,22 @@ -// Insights over packages/insights: cost KPIs, activity bars, token mosaic, -// cost-by-model, calls-by-tool, recent purpose runs, runs history, and -// run-trace detail. Data may still be EMPTY_OVERALL_USAGE / -// activitySeriesForWindow at the client boundary; zero-turn landings hide -// Cost / Activity / Tokens chrome rather than showing zero KPI tiles. Null -// cost/rate still means "rate unknown" when turns exist — em-dash, not a -// fabricated cost. -// Stage layout mirrors the shell mock: KPI row → chart/card grid → recent runs. +// Insights over the native `GET /workflows/runs` top-level listing: KPI +// row, "running now" strip, recent purpose runs, and runs history grouped +// by definition. CL-8160: the hub mounts nothing Workbench-specific any +// more (packages/insights and its `/insights/*` routes are gone), and the +// stock observability routes it could have read instead +// (`vendor/intx/hub-api/src/routes/observability.ts`'s four GET routes) +// are unimplemented stubs — each returns 501. With no data source left, +// this page dropped everything packages/insights used to compute: cost +// KPIs, token mosaic, activity bars, cost-by-model, calls-by-tool, turn +// latency, cross-workbench "activity by workbench" and the scope switcher +// that navigated between sibling workbenches, and the run-detail trace +// waterfall (steps/completed/failed/duration were derived from trace +// spans this build no longer has). What is left — the run list, its KPI +// counts, and run metadata — is entirely native `WorkflowRunResponse` +// data, already fetched the same way `mission-control-page.tsx` and +// `routines-api.ts` read runs elsewhere in this app. import { Badge, - BarChart, PageShell, RichEmptyState, RUN_STATUS_DOT_TONE, @@ -24,12 +31,8 @@ import { TableHead, TableHeader, TableRow, - TimeSeriesChart, - TokenMosaic, - TraceWaterfall, type BadgeTone, type RunStatus, - type TraceSpan, } from "@corbits/react-ui"; import { ChartBar } from "@corbits/icons"; import { @@ -38,78 +41,31 @@ import { withListingAbandoned, } from "@corbits/workflows/client"; import type * as React from "react"; -import { useEffect, useMemo, useState } from "react"; - -import { - activitySeriesForWindow, - createInsightsWindow, - durationLabel, - EMPTY_OVERALL_USAGE, - formatCount, - formatRate, - formatUsd, - INSIGHTS_WINDOW_DAYS, - modelsWithMissingRates, - modelsWithUnreportedTokens, - tokensLabel, - topModelsByCost, - type DayActivity, - type InsightsRange, - type ModelUsage, - type OverallUsage, -} from "@corbits/insights/client"; +import { useEffect, useState } from "react"; import { workflowRunStatuses, type WorkflowRunStatus } from "@intx/types"; import { SignedOutNotice, type APIQuery } from "@corbits/api-query"; -import { - workbenchesQueryKey, - listWorkbenches, - type Workbench, -} from "@corbits/chat-ui"; +import { workbenchesQueryKey, listWorkbenches } from "@corbits/chat-ui"; -import { useAPIQuery } from "../api"; import { useBench } from "../bench-context"; -import { - workbenchIdForWorkbenchTenant, - resolveWorkbenchInsightsScope, - type WorkbenchInsightsResolution, -} from "../insights-workbench-scope"; -import { workbenchInsightsPath } from "../insights-deeplinks"; +import { resolveWorkbenchInsightsScope } from "../insights-workbench-scope"; import { parseInsightsPath } from "../insights-path"; import { - ActivityResponseSchema, - InsightsScopeSchema, - LatencySummarySchema, - OverallUsageSchema, - RunTraceSchema, - ToolsResponseSchema, - TopLevelRunsSchema, - WorkbenchesResponseSchema, - insightsActivityPath, - insightsLatencyPath, - insightsRunTracePath, - insightsScopePath, - insightsToolsPath, insightsTopLevelRunsPath, - insightsUsagePath, - insightsWorkbenchesPath, + TopLevelRunsSchema, type InsightsRun, - type InsightsScope, - type LatencySummary, - type RunTrace, - type ToolCall, - type WorkbenchUsage, } from "../insights-api"; import { computeInsightsStats, - computeTraceStats, - filterRunsByCreatedAt, + durationLabel, + formatCount, groupRunsByDefinition, purposeRunsForInsights, runDisplayName, } from "../insights-stats"; import { useNavigate } from "../navigation"; import { tenantKeys } from "../query-client"; +import { useAPIQuery } from "../api"; import { INSIGHTS_PATH_PREFIX, INSIGHTS_RUNS_PATH } from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; import { @@ -119,11 +75,6 @@ import { } from "../routines-api"; import { WorkbenchTimelineRoute } from "./workbench-timeline"; -function dash(value: string | number | null | undefined): string { - if (value === null || value === undefined || value === "") return "—"; - return String(value); -} - export function formatWhen(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return iso; @@ -172,111 +123,18 @@ function tileValue(value: string | number | null, loading: boolean): string { return String(value); } -/** "1.2s / 3.4s" for a latency stage's p50/p95, or an em-dash pair when - * the stage recorded no samples in range (see LatencyStageStat). */ -function latencyStatValue(stat: { - readonly p50Ms: number | null; - readonly p95Ms: number | null; -}): string { - const p50 = stat.p50Ms === null ? "—" : durationLabel(stat.p50Ms); - const p95 = stat.p95Ms === null ? "—" : durationLabel(stat.p95Ms); - return `${p50} / ${p95}`; -} - -function tokenParts(summary: OverallUsage) { - const t = summary.tokens; - return [ - { label: "Input", value: t.input }, - { label: "Output", value: t.output }, - { label: "Cache read", value: t.cacheRead }, - { label: "Cache write", value: t.cacheWrite }, - { label: "Thinking", value: t.thinking }, - ].filter((p) => p.value > 0); -} - -export function toTraceSpans(trace: RunTrace): TraceSpan[] { - if (trace.spans === null || trace.spans.length === 0) return []; - const origin = Math.min(...trace.spans.map((s) => s.start), 0); - const end = Math.max(...trace.spans.map((s) => s.end), origin + 1); - const span = Math.max(1, end - origin); - return trace.spans.map((s) => { - const base = { - id: s.id, - label: s.label, - kind: s.kind, - start: (s.start - origin) / span, - end: (s.end - origin) / span, - durationLabel: s.durationMs === null ? null : durationLabel(s.durationMs), - phase: s.phase, - timingSource: s.timingSource, - }; - const tok = tokensLabel(s.tokens); - if (s.error !== null && tok !== undefined) { - return { ...base, tokensLabel: tok, error: s.error }; - } - if (s.error !== null) return { ...base, error: s.error }; - if (tok !== undefined) return { ...base, tokensLabel: tok }; - return base; - }); -} - -function cacheHitRate(summary: OverallUsage): number | null { - const t = summary.tokens; - const denom = t.input + t.cacheRead; - if (denom === 0) return null; - return t.cacheRead / denom; -} - -/** Weekday short label for a UTC YYYY-MM-DD activity day. */ -function dayWeekdayLabel(day: string): string { - const date = new Date(`${day}T12:00:00.000Z`); - if (Number.isNaN(date.getTime())) return day.slice(5); - return date.toLocaleDateString(undefined, { - weekday: "short", - timeZone: "UTC", - }); -} - -/** Prefer the most recent 7 buckets when the sink returns a longer window. */ -function recentActivityDays( - days: readonly DayActivity[], - limit = 7, -): readonly DayActivity[] { - if (days.length <= limit) return days; - return days.slice(days.length - limit); -} - -function runsDetailLabel(stats: { - readonly running: number; - readonly errored: number; -}): string { - if (stats.running > 0) { - return `${formatCount(stats.running)} running`; - } - if (stats.errored > 0) { - return `${formatCount(stats.errored)} errored`; - } - return "runs"; -} - function InsightsStat({ label, value, detail, onClick, loading, - sparklineValues, - sparklineLabel, }: { readonly label: string; readonly value: string; readonly detail?: string; readonly onClick?: () => void; readonly loading?: boolean; - /** Real per-day series backing this tile's trend line — omitted (not - * padded/estimated) whenever the underlying window lacks one. */ - readonly sparklineValues?: readonly number[]; - readonly sparklineLabel?: string; }) { if (loading === true) { return ( @@ -294,8 +152,6 @@ function InsightsStat({ value={value} {...(detail === undefined ? {} : { sub: detail })} {...(onClick === undefined ? {} : { onClick })} - {...(sparklineValues === undefined ? {} : { sparklineValues })} - {...(sparklineLabel === undefined ? {} : { sparklineLabel })} /> ); } @@ -318,148 +174,17 @@ function onRowActivate(onActivate: () => void) { }; } -function ActivityBars({ days }: { readonly days: readonly DayActivity[] }) { - const window = recentActivityDays(days); - return ( - ({ - label: dayWeekdayLabel(d.day), - value: d.turns, - }))} - valueLabel="Turns" - format={formatCount} - /> - ); -} - -/** Workbenches that recorded at least one turn in the window — the global - * landing's "active workbenches" KPI. Never counts a workbench that only - * exists (a leaf with zero usage) as active. */ -function activeWorkbenchCount(workbenches: readonly WorkbenchUsage[]): number { - return workbenches.filter((w) => w.turns > 0).length; -} - -const WORKBENCH_BARS_LIMIT = 8; - -/** - * Ranked activity-by-workbench list: the tenancy-wide landing's answer to - * "which workbenches are actually busy" — each row a mini bar (relative to - * the busiest workbench in view) that opens that workbench's own scoped - * view, same clickable-row affordance as the rest of this page - * (`onRowActivate`) rather than a bespoke chart interaction. - */ -function WorkbenchActivityBars({ - workbenches, - onSelectWorkbench, -}: { - readonly workbenches: readonly WorkbenchUsage[]; - readonly onSelectWorkbench: (tenantId: string) => void; -}) { - const ranked = [...workbenches] - .sort((a, b) => b.turns - a.turns) - .slice(0, WORKBENCH_BARS_LIMIT); - const max = Math.max(1, ...ranked.map((w) => w.turns)); - - return ( - - - {ranked.map((workbench) => ( - onSelectWorkbench(workbench.tenantId))} - > - -
- - {workbench.name} - -
-
-
-
- - - {formatCount(workbench.turns)} - - - ))} - -
- ); -} - -/** "Done" folds every settled-without-error outcome (deployed and manually - * stopped) together — the landing asks for done vs. failed, not a full - * status breakdown (that detail stays on the KPI tiles / run history). */ -function runOutcomeData(stats: { - readonly deployed: number; - readonly stopped: number; - readonly errored: number; +function runsDetailLabel(stats: { readonly running: number; -}) { - return [ - { label: "Done", value: stats.deployed + stats.stopped }, - { label: "Failed", value: stats.errored }, - { label: "Running", value: stats.running }, - ]; -} - -/** Tokens-by-model series for the tokens-over-time chart. Token volume is - * a known number only when the adapter reported it; unreported turns are - * excluded from this series rather than plotted as a silent zero. Capped - * to the top models by cost (the models that matter most to the spend - * story), same as `TimeSeriesChart`'s own "≤5 series" rule. */ -function tokensOverTimeSeries(days: readonly DayActivity[]) { - const models = topModelsByCost(days).filter((model) => - days.some( - (d) => (d.byModel.find((m) => m.model === model)?.tokens ?? 0) > 0, - ), - ); - return models.map((model) => ({ - label: model, - values: days.map( - (day) => day.byModel.find((m) => m.model === model)?.tokens ?? 0, - ), - })); -} - -/** Real per-day run counts, bucketed onto `activityDays`' own UTC day keys — - * the Runs KPI's sparkline shape, built from the same run records the - * recent-runs/history tables render rather than a synthesized series. */ -export function runsPerDay( - runs: readonly InsightsRun[], - days: readonly DayActivity[], -): number[] { - const counts = new Map(days.map((d) => [d.day, 0])); - for (const run of runs) { - const day = run.createdAt.slice(0, 10); - const current = counts.get(day); - if (current !== undefined) counts.set(day, current + 1); + readonly errored: number; +}): string { + if (stats.running > 0) { + return `${formatCount(stats.running)} running`; } - return days.map((d) => counts.get(d.day) ?? 0); -} - -/** Real per-day cost, summed across models — the Cost KPI's sparkline - * shape. Callers only use this when every model's rate is known for the - * window (`modelsWithMissingRates` is empty); otherwise a day with an - * unpriced model would silently read as cheaper than it was. */ -export function costPerDay(days: readonly DayActivity[]): number[] { - return days.map((d) => - d.byModel.reduce((sum, m) => sum + (m.costUsd ?? 0), 0), - ); -} - -/** Wall-clock time since a run started, in the same "2m 12s" form as the - * rest of this page (`durationLabel`) — never a fabricated live counter. */ -export function elapsedLabel(createdAt: string, now: number): string { - const startMs = Date.parse(createdAt); - if (Number.isNaN(startMs)) return "—"; - return durationLabel(Math.max(0, now - startMs)); + if (stats.errored > 0) { + return `${formatCount(stats.errored)} errored`; + } + return "runs"; } const ELAPSED_TICK_MS = 1_000; @@ -497,6 +222,14 @@ function insightsRunStatus(run: InsightsRun, now: number = Date.now()): string { return runOutcomeStatus(withListingAbandoned(run, now), now) ?? run.status; } +/** Wall-clock time since a run started, in the same "2m 12s" form as the + * rest of this page (`durationLabel`) — never a fabricated live counter. */ +export function elapsedLabel(createdAt: string, now: number): string { + const startMs = Date.parse(createdAt); + if (Number.isNaN(startMs)) return "—"; + return durationLabel(Math.max(0, now - startMs)); +} + /** * "Running now" — a horizontally scrolling strip of the runs actually in * flight this instant (`status: running | updating`), not a fabricated @@ -557,84 +290,6 @@ function RunningNowStrip({ ); } -function ModelCostTable({ - models, -}: { - readonly models: readonly ModelUsage[]; -}) { - return ( - - - - Model - Cost - Input - Cache read - Output - - - - {models.map((m) => ( - - {m.model} - - {m.costUsd === null || (m.tokens.total === 0 && m.turns > 0) - ? "—" - : formatUsd(m.costUsd)} - - - {m.tokens.total === 0 && m.turns > 0 - ? "—" - : formatCount(m.tokens.input)} - - - {m.tokens.total === 0 && m.turns > 0 - ? "—" - : formatCount(m.tokens.cacheRead)} - - - {m.tokens.total === 0 && m.turns > 0 - ? "—" - : formatCount(m.tokens.output)} - - - ))} - -
- ); -} - -function ToolCallsTable({ tools }: { readonly tools: readonly ToolCall[] }) { - return ( - - - - Tool - Calls - Errors - Error rate - - - - {tools.map((t) => ( - - {t.tool} - {formatCount(t.calls)} - {formatCount(t.errors)} - {formatRate(t.errorRate)} - - ))} - -
- ); -} - function RecentRunRows({ runs, onOpenRun, @@ -675,7 +330,7 @@ function RecentRunRows({ colSpan={2} className="font-semibold text-primary-emphasis" > - All runs & traces → + All runs → @@ -684,155 +339,51 @@ function RecentRunRows({ } function InsightsLanding({ - summary, - activity, - byModel, - byTool, runs, runsNextCursor, routines, - workbenches, - latency, - range, loading, onOpenRun, onOpenRuns, - onSelectWorkbench, }: { - readonly summary: OverallUsage | null; - readonly activity: readonly DayActivity[] | null; - readonly byModel: readonly ModelUsage[] | null; - readonly byTool: readonly ToolCall[] | null; readonly runs: readonly InsightsRun[]; /** The feed's own `nextCursor` (`limit=100` fetch, see - * `insightsTopLevelRunsPath`) — non-null means more runs exist in this - * window than the 100 fetched, so the KPIs/sparkline/outcome chart below - * disclose the cap instead of silently presenting a truncated series as - * complete. */ + * `insightsTopLevelRunsPath`) — non-null means more runs exist than the + * 100 fetched, so the KPIs below disclose the cap instead of silently + * presenting a truncated series as complete. */ readonly runsNextCursor: string | null; readonly routines: readonly ScheduledWorkflowDefinition[]; - /** Null while `/workbenches` hasn't resolved (or this landing is already - * scoped to one workbench, where a breakdown of one has nothing to - * show) — the activity-by-workbench chart and active-workbenches KPI - * both hide rather than render a fabricated single-row chart. */ - readonly workbenches: readonly WorkbenchUsage[] | null; - /** Null while `/latency` (CL-6257) hasn't resolved. */ - readonly latency: LatencySummary | null; - /** Same 7-day window as usage/activity/tools requests. */ - readonly range: InsightsRange; readonly loading: boolean; readonly onOpenRun: (id: string) => void; readonly onOpenRuns: () => void; - readonly onSelectWorkbench: (tenantId: string) => void; }) { - // KPI count + recent list only — history/detail keep full run list. - const windowedRuns = filterRunsByCreatedAt(runs, range.from, range.to); - const stats = computeInsightsStats(windowedRuns, routines); - const purposeRuns = purposeRunsForInsights(windowedRuns); - - // Liveness is not a windowed property: a run that started before - // `range.from` and is still going is running right now regardless of when - // it started, so "Running now" reads off every fetched run, never the - // range-filtered subset above. - const runningNow = purposeRunsForInsights(runs).filter((run) => - isRunningNow(run), - ); - - // Absent usage → zeros at the client boundary (never demo peaks / em-dash - // for "no spend"). Real fetched summary is preserved when present. - const usage = summary ?? EMPTY_OVERALL_USAGE; - const mosaicParts = tokenParts(usage); - const hitRate = cacheHitRate(usage); - const missingRates = modelsWithMissingRates(usage); - const unreportedTokens = modelsWithUnreportedTokens(usage); - const tokensUnreported = usage.turns > 0 && usage.tokens.total === 0; - const activityDays = activitySeriesForWindow(activity ?? [], range); - const activityWindowEmpty = recentActivityDays(activityDays).every( - (d) => d.turns === 0, - ); - const models = byModel !== null && byModel.length > 0 ? byModel : null; - const tools = byTool !== null && byTool.length > 0 ? byTool : null; + const stats = computeInsightsStats(runs, routines); + const purposeRuns = purposeRunsForInsights(runs); + const runningNow = purposeRuns.filter((run) => isRunningNow(run)); const recent = purposeRuns.slice(0, 12); - const tokensSeries = tokensOverTimeSeries(activityDays); - const noUsageInWindow = !loading && usage.turns === 0; - - // KPI sparklines: only ever a real per-day series already backing this - // window's other charts, never estimated to fill a gap. - const turnsSparkline = activityDays.map((d) => d.turns); - const tokensSparkline = activityDays.map((d) => d.tokens); - const runsSparkline = runsPerDay(purposeRuns, activityDays); - const costSparkline = - missingRates.length === 0 && unreportedTokens.length === 0 - ? costPerDay(activityDays) - : undefined; return (
- {noUsageInWindow ? null : ( - - )} - {noUsageInWindow ? null : ( - - )} - {noUsageInWindow ? null : ( - - )} - {workbenches !== null ? ( - - ) : null} + + {runningNow.length > 0 || loading ? ( - {latency !== null && latency.total.samples > 0 ? ( - - - - - {latency.toReactorStart.samples > 0 ? ( - - ) : null} - - ) : null} - - {noUsageInWindow && !activityWindowEmpty ? ( -

No usage recorded yet in this window.

- ) : null} - - {unreportedTokens.length > 0 ? ( -

- Token counts were not reported for: {unreportedTokens.join(", ")}. - Those turns do not contribute a fabricated cost or token total. -

- ) : null} - - {missingRates.length > 0 ? ( -

- Rates unknown for: {missingRates.join(", ")}. Those turns do not - contribute a fabricated cost. -

- ) : null} - {runsNextCursor !== null ? (

- Runs, sparkline, and outcomes below reflect the 100 most recent runs — - more exist in this window.{" "} + Runs and outcomes below reflect the 100 most recent runs — more exist.{" "} .

) : null} -
-
- {activityWindowEmpty ? ( - } - title="No activity yet" - description={ - noUsageInWindow - ? "No usage recorded yet in this window." - : "Activity shows up here once there are turns in this window." - } - /> - ) : ( - - )} -
- - {mosaicParts.length > 0 ? ( -
-

Token mix

- - - - - -
- ) : null} - -
-

Cost by model

- {models !== null ? ( - - ) : ( - } - title="No model usage yet" - description="Costs show up here once a model has been used in this window." - /> - )} -
- -
-

Calls by tool

- {tools !== null ? ( - - ) : ( - } - title="No tool calls yet" - description="Tool calls show up here once an agent has made one in this window." - /> - )} -
- - {tokensSeries.length > 0 ? ( -
- dayWeekdayLabel(d.day))} - series={tokensSeries} - variant="area" - format={formatCount} - /> -
- ) : null} - - {stats.totalRuns > 0 ? ( -
- -
- ) : null} - - {workbenches !== null ? ( -
-

Activity by workbench

- {workbenches.length > 0 ? ( - - ) : ( - } - title="No workbench activity yet" - description="Activity by workbench shows up here once a workbench has usage in this window." - /> - )} -
- ) : null} -
-

Recent runs

@@ -1154,17 +549,9 @@ export function InsightsRunsHistory({ export function InsightsRunDetail({ run, - trace, }: { readonly run: InsightsRun | null; - readonly trace: APIQuery; }) { - const spans = trace.kind === "ready" ? toTraceSpans(trace.data) : []; - const traceStats = - trace.kind === "ready" && !("absent" in trace.data) - ? computeTraceStats(trace.data.spans) - : null; - return (
- - {/* Owner is not carried by WorkflowRunResponse yet — dash, not - a fabricated identity. */} - - + - - {trace.kind === "loading" ? ( - - ) : null} - {trace.kind === "error" ? ( - - ) : null} - {trace.kind === "unauthenticated" ? : null} - {trace.kind === "ready" && spans.length > 0 ? ( -
-

Timeline

- -
- ) : null} - {trace.kind === "ready" && spans.length === 0 ? ( + {run === null ? ( ) : null}
@@ -1245,169 +593,23 @@ export function InsightsRunDetail({ ); } -/** - * The landing view's default scope, and every non-landing mode's scope, - * as one pure decision so it can be unit-tested without mounting the - * route. `/scope` (packages/insights/src/routes.ts) only ever reports a - * `parent` when the caller holds an active principal in it — a present - * parent means "caller is a workspace member" and the default becomes - * the cross-workbench aggregate ("All workbenches"); otherwise the - * default is the caller's own current workbench, labeled with its name. - * Either way the result is always a tenant `/scope` itself vouches the - * caller can see — there is no default that can 403. - */ -const WINDOW_REFRESH_MS = 60_000; - -/** The insights [from, to] window, re-anchored to now once a minute. */ -export function useInsightsWindow(): InsightsRange { - const [now, setNow] = useState(() => new Date()); - useEffect(() => { - const timer = setInterval(() => setNow(new Date()), WINDOW_REFRESH_MS); - return () => clearInterval(timer); - }, []); - return useMemo(() => createInsightsWindow(undefined, now), [now]); -} - -export function resolveInsightsScope({ - mode, - selectedTenantId, - scopeData, -}: { - readonly mode: "landing" | "runs" | "run" | "workbench"; - readonly selectedTenantId: string | null; - readonly scopeData: InsightsScope | null; -}): { effectiveTenantId: string | null; scopeLabel: string } { - if (mode !== "landing") { - return { - effectiveTenantId: selectedTenantId, - scopeLabel: "All workbenches", - }; - } - if (scopeData?.parent) { - return { - effectiveTenantId: scopeData.parent.tenantId, - scopeLabel: "All workbenches", - }; - } - // The root tenancy with no parent IS the aggregate: every workbench's - // runs land on it, so the landing view is all workbenches by - // definition — label it that way instead of the tenant's own name, - // which reads like a single workbench. Before `/scope` resolves this - // falls back to the same honest placeholder rather than the raw - // tenant id, so the dashboard never blocks on it and never shows one. - if (scopeData !== null) { - return { - effectiveTenantId: scopeData.tenantId, - scopeLabel: "All workbenches", - }; - } - return { - effectiveTenantId: selectedTenantId, - scopeLabel: "All workbenches", - }; -} - -/** - * Landing-view scope switcher: "All workbenches" (the cross-workbench - * aggregate this landing always shows — always the pressed option, since - * every other pill navigates straight to that sibling's own workbench-scoped - * Insights, CL-5879, rather than switching this same page's scope inline) - * versus each sibling workbench by name. Hidden entirely when `/scope` - * reports no parent — a root workbench with no siblings has nothing to - * switch to. - */ -function InsightsScopeSwitcher({ - scope, - onSelect, -}: { - readonly scope: InsightsScope | null; - readonly onSelect: (tenantId: string | null) => void; -}) { - if (scope === null || scope.parent === null) return null; - return ( -
- - {scope.workbenches.map((workbench) => ( - - ))} -
- ); -} - export function InsightsPage({ path, - summary, - activity, - byTool, runs, routines, - workbenches, - latency, - range, - scope, - resolveWorkbenchIdForTenant, - scopeLabel, }: { readonly path: string; - readonly summary: APIQuery; - readonly activity: APIQuery; - readonly byTool: APIQuery; readonly runs: APIQuery<{ data: readonly InsightsRun[]; nextCursor: string | null; }>; readonly routines: APIQuery; - /** `/workbenches` — this scope's own row plus one per descendant - * workbench, used for the "activity by workbench" chart and the - * "active workbenches" KPI. */ - readonly workbenches: APIQuery<{ - items: readonly WorkbenchUsage[]; - }>; - /** `/latency` — CL-6257 per-message-run stage p50/p95. */ - readonly latency: APIQuery; - /** Stable 7-day window created once per route mount. */ - readonly range: InsightsRange; - /** `/scope` result — own identity, parent (if any), sibling - * workbenches. Null while loading/absent; the switcher hides itself. */ - readonly scope: InsightsScope | null; - /** A workbench usage row (and the scope switcher's sibling pills) only - * carry that workbench's tenant id — this resolves it to the workbench - * that opens `/insights/workbench/:workbenchId` for it (CL-5879), or null - * when no workbench in view carries that tenancy. */ - readonly resolveWorkbenchIdForTenant: (tenantId: string) => string | null; - /** "All workbenches" or the current workbench's own name — always known - * even before `/scope` resolves (falls back to the raw id). */ - readonly scopeLabel: string; }) { const navigate = useNavigate(); const { mode, runId } = parseInsightsPath(path); - const { selectedTenantId } = useBench(); const unauth = - summary.kind === "unauthenticated" || - runs.kind === "unauthenticated" || - routines.kind === "unauthenticated"; + runs.kind === "unauthenticated" || routines.kind === "unauthenticated"; if (unauth) { return ( @@ -1420,47 +622,15 @@ export function InsightsPage({ ); } - const loading = - summary.kind === "loading" || - activity.kind === "loading" || - runs.kind === "loading" || - routines.kind === "loading"; + const loading = runs.kind === "loading" || routines.kind === "loading"; - // Usage/activity/tools errors must surface. Loading and ready-empty / - // no-tenant still use EMPTY_OVERALL_USAGE at the data boundary so the - // dashboard never invents spend; zero-turn chrome hides usage tiles. - // Runs/routines soft-empty on landing. - const usageErrorRetry = - summary.kind === "error" - ? summary.retry - : activity.kind === "error" - ? activity.retry - : byTool.kind === "error" - ? byTool.retry - : null; - - const summaryData = - summary.kind === "ready" ? summary.data : EMPTY_OVERALL_USAGE; - const activityData = activity.kind === "ready" ? activity.data : []; - const byModelData = summaryData.byModel; - const byToolData = byTool.kind === "ready" ? byTool.data : []; const runsData = runs.kind === "ready" ? runs.data.data : []; const runsNextCursor = runs.kind === "ready" ? runs.data.nextCursor : null; const routinesData = routines.kind === "ready" ? routines.data : []; - const workbenchesData = - workbenches.kind === "ready" ? workbenches.data.items : null; - const latencyData = latency.kind === "ready" ? latency.data : null; if (mode === "run" && runId !== null) { const run = runsData.find((r) => r.id === runId) ?? null; - // Only fetch trace when we have a tenant; unauthenticated already handled. - return ( - - ); + return ; } if (mode === "runs") { @@ -1476,7 +646,7 @@ export function InsightsPage({ ); } - if (usageErrorRetry !== null) { + if (runs.kind === "error") { return (
@@ -1485,7 +655,7 @@ export function InsightsPage({ icon={} title="Couldn't load insights" description="Something went wrong on our side. Try again in a moment." - actions={[{ label: "Retry", onClick: usageErrorRetry }]} + actions={[{ label: "Retry", onClick: runs.retry }]} />
@@ -1494,47 +664,18 @@ export function InsightsPage({ return (
- { - if (tenantId === null) { - navigate(INSIGHTS_PATH_PREFIX); - return; - } - const workbenchId = resolveWorkbenchIdForTenant(tenantId); - if (workbenchId !== null) - navigate(workbenchInsightsPath(workbenchId)); - }} - /> - } - /> +
navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`) } onOpenRuns={() => navigate(INSIGHTS_RUNS_PATH)} - onSelectWorkbench={(tenantId) => { - const workbenchId = resolveWorkbenchIdForTenant(tenantId); - if (workbenchId !== null) - navigate(workbenchInsightsPath(workbenchId)); - }} />
@@ -1542,23 +683,6 @@ export function InsightsPage({ ); } -export function InsightsRunDetailRoute({ - runId, - run, - tenantId, -}: { - readonly runId: string; - readonly run: InsightsRun | null; - readonly tenantId: string | null; -}) { - const trace = useAPIQuery( - tenantId === null ? "" : insightsRunTracePath(tenantId, runId), - RunTraceSchema, - ); - - return ; -} - /** * Insights scoped to one workbench (CL-5879) — `/insights/workbench/:workbenchId` * resolves the workbench's own workbench tenant (see @@ -1578,7 +702,7 @@ function InsightsWorkbenchPage({ }: { readonly workbenchId: string; readonly workbenchesLoading: boolean; - readonly resolution: WorkbenchInsightsResolution; + readonly resolution: ReturnType; readonly benchTenantId: string | null; readonly onOpenRun: (id: string) => void; }) { @@ -1627,7 +751,6 @@ function InsightsWorkbenchPage({ { label: "Insights", href: INSIGHTS_PATH_PREFIX }, { label: resolution.title }, ]} - subtitle={`Last ${INSIGHTS_WINDOW_DAYS} days`} />
@@ -1652,105 +775,10 @@ export function InsightsRoute({ path }: { readonly path?: string }) { : INSIGHTS_PATH_PREFIX); const { mode, workbenchId } = parseInsightsPath(currentPath); - // A sliding window: `to` re-anchors to now every minute, so the - // dashboard keeps up with live turns instead of freezing at whatever - // instant the page mounted. The minute granularity keeps query keys - // stable between ticks. - const range = useInsightsWindow(); - - // Own identity, parent (a workspace, if this workbench has one), and - // sibling workbenches — read once off the current workbench regardless - // of scope, since it describes the switcher options, not the data - // itself. See @corbits/insights' routes.ts `/scope`. - const scope = useAPIQuery( - selectedTenantId === null ? "" : insightsScopePath(selectedTenantId), - InsightsScopeSchema, - ); - const scopeData = scope.kind === "ready" ? scope.data : null; - - // The bench's own workbench list, shared with `ChatWorkspace`'s sidebar via - // `workbenchesQueryKey` (never a bespoke fetch of its own) — the single - // mechanism behind both directions of workbench↔workbench-tenant - // resolution (CL-5879): a `/insights/workbench/:workbenchId` deep link - // resolving its own tenant below, and a usage row's tenant id resolving - // back to the workbench that opens it (`resolveWorkbenchIdForTenant`, fed to - // `InsightsPage` for the "activity by workbench" rows and the scope - // switcher's sibling pills). - const workbenchesOfKind = useTenantQuery( - selectedTenantId === null - ? ["tenant", "none", "workbenches", "workbench"] - : workbenchesQueryKey(selectedTenantId, "workbench"), - selectedTenantId !== null, - () => listWorkbenches(selectedTenantId as string, "workbench"), - ); - const chatsOfKind = useTenantQuery( - selectedTenantId === null - ? ["tenant", "none", "workbenches", "chat"] - : workbenchesQueryKey(selectedTenantId, "chat"), - selectedTenantId !== null, - () => listWorkbenches(selectedTenantId as string, "chat"), - ); - const workbenchesLoading = - workbenchesOfKind.kind === "loading" || chatsOfKind.kind === "loading"; - const allWorkbenches: readonly Workbench[] = [ - ...(workbenchesOfKind.kind === "ready" ? workbenchesOfKind.data : []), - ...(chatsOfKind.kind === "ready" ? chatsOfKind.data : []), - ]; - const resolveWorkbenchIdForTenant = (tenantId: string): string | null => - workbenchIdForWorkbenchTenant(allWorkbenches, tenantId); - const workbenchResolution: WorkbenchInsightsResolution | null = - mode === "workbench" && workbenchId !== null - ? resolveWorkbenchInsightsScope(allWorkbenches, workbenchId) - : null; - - const { effectiveTenantId, scopeLabel } = resolveInsightsScope({ - mode, - selectedTenantId, - scopeData, - }); - - const summary = useAPIQuery( - effectiveTenantId === null - ? "" - : insightsUsagePath(effectiveTenantId, range), - OverallUsageSchema, - ); - const activityRaw = useAPIQuery( - effectiveTenantId === null - ? "" - : insightsActivityPath(effectiveTenantId, range), - ActivityResponseSchema, - ); - const toolsRaw = useAPIQuery( - effectiveTenantId === null - ? "" - : insightsToolsPath(effectiveTenantId, range), - ToolsResponseSchema, - ); const runs = useAPIQuery( - effectiveTenantId === null - ? "" - : insightsTopLevelRunsPath(effectiveTenantId), + selectedTenantId === null ? "" : insightsTopLevelRunsPath(selectedTenantId), TopLevelRunsSchema, ); - // Only meaningful on the cross-workbench landing — mode "workbench" renders - // `InsightsWorkbenchPage` instead of `InsightsPage`, so there is nothing - // here to chart and the fetch stays disabled. - const workbenches = useAPIQuery( - effectiveTenantId === null || mode !== "landing" - ? "" - : insightsWorkbenchesPath(effectiveTenantId, range), - WorkbenchesResponseSchema, - ); - // CL-6257 turn-latency tiles: same landing-only scope as `workbenches` - // above (the per-workbench route renders `InsightsWorkbenchPage`'s - // timeline instead of this landing, so there is nothing here to show). - const latency = useAPIQuery( - effectiveTenantId === null || mode !== "landing" - ? "" - : insightsLatencyPath(effectiveTenantId, range), - LatencySummarySchema, - ); const routines = useTenantQuery( selectedTenantId === null ? ["tenant", "none", "routines"] @@ -1762,44 +790,18 @@ export function InsightsRoute({ path }: { readonly path?: string }) { const routinesForPage: APIQuery = selectedTenantId === null ? { kind: "ready", data: [] } : routines; - // Unwrap package envelopes ({ days }, { tools }) for the page surface. - const activity: APIQuery = - activityRaw.kind === "ready" - ? { kind: "ready", data: activityRaw.data.days } - : activityRaw; - const byTool: APIQuery = - toolsRaw.kind === "ready" - ? { kind: "ready", data: toolsRaw.data.tools } - : toolsRaw; - - // No tenant in scope: zero usage/run defaults so the page shows an - // honest empty state without inventing nonzero workbench usage or runs. - const emptySummary: APIQuery = - effectiveTenantId === null - ? { kind: "ready", data: EMPTY_OVERALL_USAGE } - : summary; - const activityForPage: APIQuery = - effectiveTenantId === null ? { kind: "ready", data: [] } : activity; - const byToolForPage: APIQuery = - effectiveTenantId === null ? { kind: "ready", data: [] } : byTool; const runsForPage: APIQuery<{ data: readonly InsightsRun[]; nextCursor: string | null; }> = - effectiveTenantId === null + selectedTenantId === null ? { kind: "ready", data: { data: [], nextCursor: null } } : runs; - if ( - mode === "workbench" && - workbenchId !== null && - workbenchResolution !== null - ) { + if (mode === "workbench" && workbenchId !== null) { return ( - navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`) @@ -1811,17 +813,61 @@ export function InsightsRoute({ path }: { readonly path?: string }) { return ( ); } + +/** Resolves the workbench-scoped route's own workbench list — split out of + * `InsightsRoute` so the landing/runs/run-detail modes above never pay for + * a workbench-list fetch they don't need. */ +function InsightsWorkbenchPageRoute({ + workbenchId, + benchTenantId, + onOpenRun, +}: { + readonly workbenchId: string; + readonly benchTenantId: string | null; + readonly onOpenRun: (id: string) => void; +}) { + const { workbenches, chats, isLoading } = + useWorkbenchAndChatLists(benchTenantId); + const resolution = resolveWorkbenchInsightsScope( + [...workbenches, ...chats], + workbenchId, + ); + return ( + + ); +} + +function useWorkbenchAndChatLists(tenantId: string | null) { + const workbenchesOfKind = useTenantQuery( + tenantId === null + ? ["tenant", "none", "workbenches", "workbench"] + : workbenchesQueryKey(tenantId, "workbench"), + tenantId !== null, + () => listWorkbenches(tenantId as string, "workbench"), + ); + const chatsOfKind = useTenantQuery( + tenantId === null + ? ["tenant", "none", "workbenches", "chat"] + : workbenchesQueryKey(tenantId, "chat"), + tenantId !== null, + () => listWorkbenches(tenantId as string, "chat"), + ); + return { + workbenches: + workbenchesOfKind.kind === "ready" ? workbenchesOfKind.data : [], + chats: chatsOfKind.kind === "ready" ? chatsOfKind.data : [], + isLoading: + workbenchesOfKind.kind === "loading" || chatsOfKind.kind === "loading", + }; +} diff --git a/apps/web/src/pages/mission-control-page.tsx b/apps/web/src/pages/mission-control-page.tsx index d5d59e5cbe..b1401b7cbd 100644 --- a/apps/web/src/pages/mission-control-page.tsx +++ b/apps/web/src/pages/mission-control-page.tsx @@ -2,10 +2,12 @@ // decision, what's running, and a way back into recent context. A new // top-level route (`/mission-control`), never `/` — `/` stays the Myra // land-hop redirect (see routes.tsx's header comment). Every panel here is -// backed by a query already used elsewhere in this app (pending -// approvals, top-level runs, insights activity); nothing on this page is -// invented. A panel with no honest data source renders an empty state -// naming what's missing instead of a fabricated number. +// backed by a query already used elsewhere in this app (pending approvals, +// top-level runs); nothing on this page is invented. A panel with no honest +// data source renders an empty state naming what's missing instead of a +// fabricated number. CL-8160 dropped the "Runs today" / "Spend today" KPI +// tiles and the "This week" panel — both read packages/insights routes with +// no stock equivalent. import { Badge, @@ -27,10 +29,9 @@ import { } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; import { ChatCircleDots, Plus, Robot } from "@corbits/icons"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { formatUsd } from "@corbits/insights/client"; import { CHAT_STRINGS, type Workbench } from "@corbits/chat-ui"; import { runOutcomeStatus, @@ -38,10 +39,8 @@ import { withListingAbandoned, } from "@corbits/workflows/client"; -import { approveApproval, rejectApproval, useAPIQuery } from "../api"; +import { approveApproval, rejectApproval } from "../api"; import { useBench } from "../bench-context"; -import { ActivityResponseSchema, insightsActivityPath } from "../insights-api"; -import { Link } from "../navigation"; import { usePendingApprovals, type PendingApproval, @@ -230,17 +229,6 @@ export function MissionControlRoute({ const { selectedTenantId: tenantId } = useBench(); const approvalsQuery = usePendingApprovals(tenantId); const activity = useBenchActivity(tenantId); - const activityRange = useMemo( - () => ({ - from: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), - to: new Date().toISOString(), - }), - [], - ); - const insightsActivity = useAPIQuery( - tenantId === null ? "" : insightsActivityPath(tenantId, activityRange), - ActivityResponseSchema, - ); const pendingApprovals = approvalsQuery.kind === "ready" ? approvalsQuery.data : null; @@ -268,27 +256,6 @@ export function MissionControlRoute({ ) : []; - const days = - insightsActivity.kind === "ready" ? insightsActivity.data.days : []; - const insightsReady = insightsActivity.kind === "ready"; - const todayKey = new Date().toISOString().slice(0, 10); - const today = days.find((day) => day.day === todayKey) ?? null; - const priorDays = days.filter((day) => day.day !== todayKey); - const avgTurns = - priorDays.length > 0 - ? priorDays.reduce((sum, day) => sum + day.turns, 0) / priorDays.length - : null; - // Ready but no today row means zero activity today, not "unknown". Dash - // only while the activity query is still settling (or cost rates unknown). - const runsToday = insightsReady ? (today?.turns ?? 0) : null; - const todaySpend = !insightsReady - ? null - : today === null - ? 0 - : today.byModel.some((model) => model.costUsd === null) - ? null - : today.byModel.reduce((sum, model) => sum + (model.costUsd ?? 0), 0); - return (
- -
@@ -520,48 +473,6 @@ export function MissionControlRoute({
) : null}
- -
-
-

This week

- - Insights → - -
- {insightsActivity.kind === "loading" ? ( - - ) : null} - {insightsActivity.kind === "error" ? ( -

- Couldn't load this week's activity. -

- ) : null} - {insightsActivity.kind === "ready" ? ( -

- {days - .reduce((sum, day) => sum + day.turns, 0) - .toLocaleString()}{" "} - runs ·{" "} - {formatUsd( - days.some((day) => - day.byModel.some((model) => model.costUsd === null), - ) - ? null - : days.reduce( - (sum, day) => - sum + - day.byModel.reduce( - (modelSum, model) => - modelSum + (model.costUsd ?? 0), - 0, - ), - 0, - ), - )}{" "} - spend -

- ) : null} -
diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index fa003bef1f..1cac5a77c4 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -1,10 +1,10 @@ // The one sidebar. Header: the brand mark, then create + search. Body: // Agents and Channels — nothing page-scoped ever renders here. Footer: the // first-run rail is Routines, Files, Skills, Agents, Plugins; Insights -// joins only when the existing usage reads return real items (never a +// joins only when the existing run reads return real items (never a // fabricated row, never a new analytics store). Below the rail: // the account row — avatar + name, the whole row is the trigger for a menu -// that pops upward with weekly usage, settings, feedback, and log out. +// that pops upward with settings, feedback, and log out. // Always present; there is no collapse affordance and no second nav column. // Approvals belong in the conversation, not as a standing band here. // @@ -32,7 +32,6 @@ import { SidebarPanelFooter, } from "@corbits/react-ui"; import { - CaretRight, ChartBar, ChatCircleDots, FolderOpen, @@ -45,18 +44,13 @@ import { SlidersHorizontal, SquaresFour, } from "@corbits/icons"; -import { useMemo } from "react"; import { CHAT_STRINGS, avatarClassForPrincipal } from "@corbits/chat-ui"; -import { - createInsightsWindow, - usageChromeLabel, -} from "@corbits/insights/client"; import webPackage from "../../package.json"; import { useAPIQuery } from "../api"; import { useBench } from "../bench-context"; -import { OverallUsageSchema, insightsUsagePath } from "../insights-api"; +import { TopLevelRunsSchema, insightsTopLevelRunsPath } from "../insights-api"; import { matchesRoute, MISSION_CONTROL_PATH, @@ -72,41 +66,6 @@ import { WorkbenchList } from "./workbench-list"; * from `git remote`) rather than a hardcoded org/repo guess. */ const FEEDBACK_URL = `${webPackage.repository.url}/issues`; -/** - * One-line 7-day cost/token summary, read off the same cheap `/usage` - * route the Insights landing tiles already use (CL-6132). No tenant, no - * data yet, or a load error all render the same honest fallback — a plain - * "Weekly usage" link with no number — never a fabricated figure. Ready - * zero usage uses `usageChromeLabel` (`$0.00`, never `$0.00 · 0 tok`). - */ -function WeeklyUsageMenuItem({ - onNavigate, -}: { - readonly onNavigate: (to: string) => void; -}) { - const { selectedTenantId } = useBench(); - const range = useMemo(() => createInsightsWindow(), []); - const usageQuery = useAPIQuery( - selectedTenantId === null ? "" : insightsUsagePath(selectedTenantId, range), - OverallUsageSchema, - ); - const usage = usageQuery.kind === "ready" ? usageQuery.data : null; - const summary = usage === null ? null : usageChromeLabel(usage); - - return ( - onNavigate("/insights")} - className="shell-sidebar-account-menu-usage" - > - Weekly usage - - {summary} - - - - ); -} - export function Sidebar({ path, user, @@ -119,12 +78,12 @@ export function Sidebar({ readonly onSignOut: () => void; }) { const { selectedTenantId } = useBench(); - const range = useMemo(() => createInsightsWindow(), []); - const usageQuery = useAPIQuery( - selectedTenantId === null ? "" : insightsUsagePath(selectedTenantId, range), - OverallUsageSchema, + const runsQuery = useAPIQuery( + selectedTenantId === null ? "" : insightsTopLevelRunsPath(selectedTenantId), + TopLevelRunsSchema, ); - const showInsights = usageQuery.kind === "ready" && usageQuery.data.turns > 0; + const showInsights = + runsQuery.kind === "ready" && runsQuery.data.data.length > 0; return ( {/* Footer order: Routines, Files, Skills, Agents, Plugins, then Insights only when that existing read proves real - items, then the account row anchors everything else (weekly - usage, Settings, Log out) in its pop-up menu — a single footer, + items, then the account row anchors everything else (settings, + feedback, log out) in its pop-up menu — a single footer, never two stacked rows. Routines (CL-6362) is global-only here — no per-workbench routines chrome remains. */} ', - ); - expect(markup).toContain( - 'aria-pressed="false" data-active="false" class="insights-scope-switcher-option">Support', - ); - }); - - test("hides the switcher entirely for a workbench with no parent (nothing to switch to)", () => { - const markup = renderLandingWithScope({ - scope: { tenantId: "tnt_a", name: "Solo", parent: null, workbenches: [] }, - scopeLabel: "All workbenches", - }); - expect(markup).not.toContain("insights-scope-switcher"); - }); - - test("hides the switcher while scope has not resolved yet", () => { - const markup = renderLandingWithScope({ - scope: null, - scopeLabel: "All workbenches", - }); - expect(markup).not.toContain("insights-scope-switcher"); - }); -}); - -function renderAtPathWithRuns(path: string, nextCursor: string | null): string { - return renderToStaticMarkup( - - undefined}> - - null} - scopeLabel="All workbenches" - /> - - - , - ); -} - -describe("InsightsPage landing run-cap disclosure", () => { - // The feed is fetched at a fixed limit=100 (see insightsTopLevelRunsPath). - // A non-null nextCursor means the window truly holds more runs than were - // fetched, so the KPIs/sparkline/outcome chart built from that truncated - // set must say so rather than presenting it as the complete series — - // InsightsRunsHistory already discloses its own cap; the landing must too. - test("a non-null nextCursor discloses the 100-run cap on the landing view", () => { - const markup = renderAtPathWithRuns("/insights", "cursor_2"); - expect(markup).toContain("100 most recent runs"); - }); - - test("a null nextCursor (fewer than 100 runs) shows no cap disclosure", () => { - const markup = renderAtPathWithRuns("/insights", null); - expect(markup).not.toContain("100 most recent runs"); - }); -}); - -describe("InsightsPage breadcrumbs", () => { - test("runs history puts an Insights / Run history trail in the top bar", () => { - const markup = renderAtPath("/insights/runs"); - expect(markup).toContain('data-testid="stage-top-bar"'); - expect(markup).toContain('aria-label="Breadcrumb"'); - expect(markup).toContain('href="/insights"'); - expect(markup).toContain('aria-current="page">Run history'); - expect(markup).not.toContain("insights-crumb"); - }); - - test("run detail puts a Runs / {run} trail in the top bar", () => { - const markup = renderAtPath("/insights/runs/run_1"); - expect(markup).toContain('aria-label="Breadcrumb"'); - expect(markup).toContain('href="/insights/runs"'); - expect(markup).toContain('aria-current="page">Morning brief'); - expect(markup).not.toContain("insights-crumb"); - }); -}); - -describe("InsightsPage run-detail stat strip", () => { - test("shows the Owner/Steps/Completed/Failed/Duration set, honestly dashed without a trace", () => { - const markup = renderAtPath("/insights/runs/run_1"); - expect(markup).toContain(">Owner<"); - expect(markup).toContain(">Steps<"); - expect(markup).toContain(">Completed<"); - expect(markup).toContain(">Failed<"); - expect(markup).toContain(">Duration<"); - expect(markup).not.toContain(">Status<"); - expect(markup).not.toContain(">Bench<"); - }); - - test("while the trace is loading, the KPIs render a shimmer, not a dash", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain('data-slot="skeleton"'); - // Owner is genuinely absent from WorkflowRunResponse today (not a - // loading state), so it keeps its dash even while the trace loads. - expect(markup).toContain(">—<"); - }); - - test("once the trace is ready-but-empty, the KPIs fall back to a genuine dash", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).not.toContain(">…<"); - }); -}); - -describe("InsightsPage trace timeline honesty", () => { - const measuredSpan = { - id: "turn_1", - label: "Turn 1", - kind: "turn", - start: 0, - end: 5000, - durationMs: 5000, - tokens: null, - phase: "ok", - error: null, - timingSource: "measured", - } as const; - - const ordinalSpanWithNoDuration = { - id: "part_1", - label: "echo", - kind: "tool", - start: 1200, - end: 1200, - durationMs: null, - tokens: null, - phase: "ok", - error: null, - timingSource: "ordinal", - } as const; - - function traceQuery(spans: RunTrace["spans"]): APIQuery { - return { - kind: "ready", - data: { runId: "run_1", spans } as RunTrace, - }; - } - - test("a tool span positioned only by event order never gets a fabricated duration", () => { - const markup = renderToStaticMarkup( - , - ); - // The ordinal span's own duration cell reads as an honest dash, never a - // computed 0ms/instant duration derived from its equal start/end. - expect(markup).toContain(">—<"); - expect(markup).not.toContain("0ms"); - }); - - test("a turn span with real measured timing still renders its actual duration", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("5.0s"); - }); -}); - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -function mount(node: React.ReactElement): HTMLDivElement { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render(node); - }); - return container; -} - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - container?.remove(); - container = null; -}); - -function insightsRun( - partial: Partial & Pick, -): InsightsRun { - return { - tenantId: "t1", - definitionId: "wfd_a", - definitionName: "Research brief", - address: "addr", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - routineId: partial.routineId ?? null, - routineName: partial.routineName ?? null, - ...partial, - }; -} - -describe("InsightsRunsHistory definition grouping", () => { - test("renders one table per definition, newest run first within each", () => { - const runs = [ - insightsRun({ - id: "a1", - status: "deployed", - definitionId: "wfd_a", - definitionName: "Research brief", - createdAt: "2026-01-01T00:00:00.000Z", - }), - insightsRun({ - id: "b1", - status: "running", - definitionId: "wfd_b", - definitionName: "Weekly digest", - createdAt: "2026-01-02T00:00:00.000Z", - }), - insightsRun({ - id: "a2", - status: "error", - definitionId: "wfd_a", - definitionName: "Research brief", - createdAt: "2026-01-03T00:00:00.000Z", - }), - ]; - - const el = mount( - undefined} - />, - ); - - const groups = el.querySelectorAll("[data-definition-group]"); - expect(groups.length).toBe(2); - expect(groups[0]?.getAttribute("data-definition-group")).toBe("wfd_a"); - const firstGroupRows = groups[0]?.querySelectorAll("tbody tr") ?? []; - expect(firstGroupRows.length).toBe(2); - expect(firstGroupRows[0]?.textContent).toContain("Failed"); - expect(el.textContent).not.toContain("Showing the 100 most recent runs."); - }); - - test("no runs renders an honest empty state, not empty tables", () => { - const el = mount( - undefined} - />, - ); - expect(el.querySelector("[data-definition-group]")).toBeNull(); - expect(el.textContent).toContain("No runs yet"); - }); - - test("a non-null nextCursor tells the reader more runs exist beyond the 100 shown", () => { - const el = mount( - undefined} - />, - ); - expect(el.textContent).toContain("Showing the 100 most recent runs."); - }); - - test("a routine fire's group header renders the routine's human name, not its definition name", () => { - const el = mount( - undefined} - />, - ); - expect(el.textContent).toContain("Pulse check"); - expect(el.textContent).not.toContain("workbench-digest"); - }); -}); - -// CL-6224: the global (all-workbenches) landing's KPI band and -// activity-by-workbench chart, fed by `/insights/workbenches`. -describe("InsightsPage global landing — KPIs and activity by workbench", () => { - const usageWithSpend: OverallUsage = { - turns: 42, - tokens: { - input: 1000, - cacheRead: 0, - cacheWrite: 0, - output: 500, - thinking: 0, - total: 1500, - }, - costUsd: 12.5, - byModel: [], - }; - - const workbenches: readonly WorkbenchUsage[] = [ - { - tenantId: "tnt_a", - name: "Support", - turns: 30, - tokens: { - input: 900, - cacheRead: 0, - cacheWrite: 0, - output: 400, - thinking: 0, - total: 1300, - }, - costUsd: 10, - }, - { - tenantId: "tnt_b", - name: "Sales", - turns: 12, - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 100, - thinking: 0, - total: 200, - }, - costUsd: 2.5, - }, - { - tenantId: "tnt_c", - name: "Quiet workbench", - turns: 0, - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }, - costUsd: 0, - }, - ]; - - function mountGlobalLanding(navigate: (path: string) => void) { - return mount( - - - - - tenantId === "tnt_b" ? "ch_b" : null - } - scopeLabel="All workbenches" - /> - - - , - ); - } - - test("renders the KPI band from stubbed usage/workbenches data", () => { - const el = mountGlobalLanding(() => undefined); - expect(el.textContent).toContain("$12.50"); - expect(el.textContent).toContain("Tokens in / out"); - expect(el.textContent).toContain("1,000 / 500"); - expect(el.textContent).toContain("Active workbenches"); - // 2 of 3 workbenches recorded turns > 0 in this fixture. - expect(el.textContent).toContain("2 / 3"); - }); - - test("ranks workbenches by turns and links each bar to its own scoped view", () => { - const el = mountGlobalLanding(() => undefined); - expect(el.textContent).toContain("Activity by workbench"); - const rows = [ - ...el.querySelectorAll( - "table[aria-label='Activity by workbench'] tbody tr", - ), - ]; - expect( - rows.map( - (r) => r.textContent?.match(/Support|Sales|Quiet workbench/)?.[0], - ), - ).toEqual(["Support", "Sales", "Quiet workbench"]); - }); - - test("clicking a workbench bar resolves its workbench and navigates to /insights/workbench/:workbenchId", () => { - const navigated: string[] = []; - const el = mountGlobalLanding((path) => navigated.push(path)); - const salesRow = [...el.querySelectorAll("tbody tr")].find((row) => - row.textContent?.includes("Sales"), - ); - expect(salesRow).toBeDefined(); - act(() => { - salesRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(navigated).toEqual(["/insights/workbench/ch_b"]); - }); - - test("a workbench with no resolvable workbench never gets a broken link", () => { - const navigated: string[] = []; - const el = mount( - - navigated.push(path)}> - - null} - scopeLabel="All workbenches" - /> - - - , - ); - const salesRow = [...el.querySelectorAll("tbody tr")].find((row) => - row.textContent?.includes("Sales"), - ); - act(() => { - salesRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(navigated).toEqual([]); - }); - - test("an all-zero workbenches window shows the honest empty note, not a fabricated chart", () => { - const el = mount( - - undefined}> - - null} - scopeLabel="All workbenches" - /> - - - , - ); - expect(el.textContent).toContain("No usage recorded yet in this window."); - expect(el.textContent).toContain("Activity by workbench"); - expect(el.textContent).toContain("No workbench activity yet"); - }); -}); - -describe("InsightsPage global landing — turn latency tiles (CL-6257)", () => { - function mountWithLatency(latency: APIQuery) { - return mount( - - undefined}> - - null} - scopeLabel="All workbenches" - /> - - - , - ); - } - - test("no latency samples in range: tiles stay hidden, not zeroed", () => { - const el = mountWithLatency(emptyLatency); - expect(el.textContent).not.toContain("Turn latency"); - }); - - test("renders p50/p95 stage tiles once samples exist", () => { - const el = mountWithLatency({ - kind: "ready", - data: { - toReactorStart: { p50Ms: null, p95Ms: null, samples: 0 }, - toInferenceStart: { p50Ms: 200, p95Ms: 400, samples: 5 }, - toFirstToken: { p50Ms: 1_200, p95Ms: 3_400, samples: 5 }, - toReplyPosted: { p50Ms: 2_000, p95Ms: 4_000, samples: 5 }, - total: { p50Ms: 3_400, p95Ms: 8_000, samples: 5 }, - }, - }); - expect(el.textContent).toContain("Turn latency (p50 / p95)"); - expect(el.textContent).toContain("3.4s / 8.0s"); - expect(el.textContent).toContain("To first token (p50 / p95)"); - expect(el.textContent).toContain("1.2s / 3.4s"); - expect(el.textContent).toContain("wait until first token"); - expect(el.textContent).toContain("first token → reply posted"); - expect(el.textContent).not.toContain("inference start"); - expect(el.textContent).not.toContain("reactor"); - // No cold starts this window — that stage tile does not render at all. - expect(el.textContent).not.toContain("Cold start"); - }); - - test("a cold-start sample surfaces its own reactor-start tile", () => { - const el = mountWithLatency({ - kind: "ready", - data: { - toReactorStart: { p50Ms: 30_000, p95Ms: 45_000, samples: 1 }, - toInferenceStart: { p50Ms: 500, p95Ms: 500, samples: 1 }, - toFirstToken: { p50Ms: 2_000, p95Ms: 2_000, samples: 1 }, - toReplyPosted: { p50Ms: 1_000, p95Ms: 1_000, samples: 1 }, - total: { p50Ms: 33_500, p95Ms: 33_500, samples: 1 }, - }, - }); - expect(el.textContent).toContain("Cold start (p50 / p95)"); - expect(el.textContent).toContain("30.0s / 45.0s"); - expect(el.textContent).toContain("wait before the model starts"); - expect(el.textContent).not.toContain("reactor"); - expect(el.textContent).not.toContain("inference start"); - }); -}); diff --git a/apps/web/test/mission-control-page.test.tsx b/apps/web/test/mission-control-page.test.tsx index 525c8342cf..4f7a023e53 100644 --- a/apps/web/test/mission-control-page.test.tsx +++ b/apps/web/test/mission-control-page.test.tsx @@ -194,49 +194,6 @@ function stubEmptyBenchFetch(): void { }) as typeof fetch; } -function stubBenchFetchWithActivity( - days: { - day: string; - turns: number; - tokens: number; - byModel: { model: string; tokens: number; costUsd: number | null }[]; - }[], -): void { - globalThis.fetch = ((input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/approvals")) { - return Promise.resolve( - new Response(JSON.stringify({ data: [], nextCursor: null }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - } - if (url.includes("/insights/activity")) { - return Promise.resolve( - new Response(JSON.stringify({ days }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - } - if (url.includes("/agent-definitions/visible")) { - return Promise.resolve( - new Response(JSON.stringify({ definitions: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - } - return Promise.resolve( - new Response(JSON.stringify({ items: [], data: [], nextCursor: null }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - }) as typeof fetch; -} - describe("MissionControlRoute", () => { let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -282,108 +239,4 @@ describe("MissionControlRoute", () => { expect(container.textContent).toContain("Routine activity has no feed"); expect(container.textContent).toContain("Nothing recent yet."); }); - - test("ready activity with no today row shows 0 / $0.00, not double em-dash (CL-6798)", async () => { - stubEmptyBenchFetch(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - undefined}> - - undefined} /> - - - , - ); - }); - for (let count = 0; count < 5; count += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - const text = container.textContent ?? ""; - // Ready + missing today is zero, not unknown — one empty treatment, not - // value em-dash stacked on sub em-dash. - expect(text).toContain("Runs today"); - expect(text).not.toMatch(/Runs today—/); - expect(text).toMatch(/Runs today0/); - expect(text).toContain("Spend today"); - expect(text).toContain("$0.00"); - expect(text).not.toContain("7-day activity"); - expect(text).toContain("so far today"); - }); - - test("Spend today keeps em-dash when today's cost rate is unknown (CL-6798)", async () => { - const todayKey = new Date().toISOString().slice(0, 10); - stubBenchFetchWithActivity([ - { - day: todayKey, - turns: 3, - tokens: 50, - byModel: [{ model: "m", tokens: 50, costUsd: null }], - }, - ]); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - undefined}> - - undefined} /> - - - , - ); - }); - for (let count = 0; count < 5; count += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - const text = container.textContent ?? ""; - expect(text).toMatch(/Runs today3/); - expect(text).toContain("so far today"); - expect(text).not.toContain("7-day activity"); - // Cost unknown → em-dash for spend value; subcopy still names today. - expect(text).toMatch(/Spend today—so far today/); - }); - - test("This week's run count includes today, so it is never less than runs today (CL-6667)", async () => { - const todayKey = new Date().toISOString().slice(0, 10); - const yesterdayKey = new Date(Date.now() - 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); - stubBenchFetchWithActivity([ - { day: yesterdayKey, turns: 4, tokens: 100, byModel: [] }, - { day: todayKey, turns: 10, tokens: 200, byModel: [] }, - ]); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - undefined}> - - undefined} /> - - - , - ); - }); - for (let count = 0; count < 5; count += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - // Today (10) + yesterday (4) = 14. A "This week" that silently excludes - // today would show 4, which is less than "Runs today" (10) -- the - // logical impossibility CL-6667 reported. - expect(container.textContent).toContain("14 runs"); - }); }); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index f7ee9184eb..f72d51cded 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -54,27 +54,25 @@ function json(body: unknown, status = 200): Response { }); } -const emptyTokens = { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, -}; - -function usageBody(turns: number): unknown { +function runsBody(count: number): unknown { return { - turns, - tokens: { ...emptyTokens, total: turns }, - costUsd: turns > 0 ? 1.25 : 0, - byModel: [], + data: Array.from({ length: count }, (_, i) => ({ + id: `run_${i}`, + tenantId: "tnt_1", + definitionId: "wfd_a", + definitionName: "Research brief", + address: "addr", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + })), + nextCursor: null, }; } function stubFetch(options?: { - readonly usageTurns?: number; - readonly failUsage?: boolean; + readonly runCount?: number; + readonly failRuns?: boolean; }): void { globalThis.fetch = ((input: RequestInfo | URL) => { const path = typeof input === "string" ? input : String(input); @@ -82,10 +80,10 @@ function stubFetch(options?: { return Promise.resolve(json(membership)); if (path.includes("/agent-definitions/visible")) return Promise.resolve(json({ definitions: [] })); - if (path.includes("/insights/usage")) { - if (options?.failUsage === true) + if (path.includes("/workflows/runs")) { + if (options?.failRuns === true) return Promise.resolve(json({ error: "unavailable" }, 500)); - return Promise.resolve(json(usageBody(options?.usageTurns ?? 0))); + return Promise.resolve(json(runsBody(options?.runCount ?? 0))); } return Promise.resolve(json({ items: [] })); }) as typeof fetch; @@ -221,8 +219,8 @@ describe("Sidebar", () => { ); }); - test("first-run footer rail does not list Insights before there is honest usage", async () => { - stubFetch({ usageTurns: 0 }); + test("first-run footer rail does not list Insights before there are honest runs", async () => { + stubFetch({ runCount: 0 }); const { container, root } = await mountSidebar("/w"); expect(footerRowLabelsFromDom(container)).toEqual([ "Routines", @@ -269,8 +267,8 @@ describe("Sidebar", () => { expect(elsewhere).not.toMatch(/>Routines<[\s\S]{0,80}aria-current="page"/); }); - test("Insights joins the footer rail only when usage has turns", async () => { - stubFetch({ usageTurns: 4 }); + test("Insights joins the footer rail only when top-level runs exist", async () => { + stubFetch({ runCount: 4 }); const { container, root } = await mountSidebar("/insights"); expect(footerRowLabelsFromDom(container)).toEqual([ "Routines", @@ -289,7 +287,7 @@ describe("Sidebar", () => { }); test("a failed usage probe omits the row rather than claiming usage", async () => { - stubFetch({ failUsage: true }); + stubFetch({ failRuns: true }); const { container, root } = await mountSidebar("/w"); expect(footerRowLabelsFromDom(container)).toEqual([ "Routines", @@ -712,14 +710,17 @@ describe("Sidebar", () => { container.remove(); }); - test("offers a Weekly usage line, a feedback link, and Log out", async () => { + // CL-8160: the "Weekly usage" line read packages/insights' `/usage` + // route, deleted along with the package — the menu is just feedback and + // log out now. + test("offers a feedback link and Log out, no Weekly usage line", async () => { stubFetch(); const container = document.createElement("div"); document.body.appendChild(container); const root = await openAccountMenu(container); const menu = document.querySelector('[role="menu"]'); - expect(menu?.textContent).toContain("Weekly usage"); + expect(menu?.textContent).not.toContain("Weekly usage"); expect(menu?.textContent).toContain("Send Feedback"); expect(menu?.textContent).toContain("Log out"); // Settings moved out to its own direct control (see the test @@ -737,34 +738,6 @@ describe("Sidebar", () => { container.remove(); }); - // CL-6877: empty weekly usage is `$0.00`, never `$0.00 · 0 tok`. - test("Weekly usage at zero spend shows $0.00 without 0 tok chrome", async () => { - stubFetch({ usageTurns: 0 }); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = await openAccountMenu(container); - - // Wait for the usage query to settle into the menu value. - for (let i = 0; i < 20; i++) { - const value = document.querySelector( - ".shell-sidebar-account-menu-usage-value", - ); - if (value?.textContent?.includes("$0.00") === true) break; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - - const value = document.querySelector( - ".shell-sidebar-account-menu-usage-value", - ); - expect(value?.textContent).toContain("$0.00"); - expect(value?.textContent).not.toContain("0 tok"); - - act(() => root.unmount()); - container.remove(); - }); - test("Log out is danger-styled and calls onSignOut", async () => { stubFetch(); const container = document.createElement("div"); diff --git a/bun.lock b/bun.lock index 01e52cb607..ab2f2fe842 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,7 @@ "@corbits/granola-call-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inbox": "workspace:*", - "@corbits/insights": "workspace:*", + "@corbits/inference-catalog": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/mcp-tools": "workspace:*", @@ -164,7 +164,6 @@ "@corbits/icons": "workspace:*", "@corbits/inbox": "workspace:*", "@corbits/inference-settings": "workspace:*", - "@corbits/insights": "workspace:*", "@corbits/plugins-ui": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", @@ -848,28 +847,6 @@ "typescript": "catalog:", }, }, - "packages/insights": { - "name": "@corbits/insights", - "version": "0.0.1", - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", - "@corbits/provider-pricing": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/log": "0.3.0", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/interaction-tools": { "name": "@corbits/interaction-tools", "version": "0.0.9", @@ -1693,7 +1670,7 @@ "name": "@corbits/assistant-workflow", "version": "0.0.2", "dependencies": { - "@corbits/catalog-tools": "0.0.6", + "@corbits/catalog-tools": "0.1.0", }, "devDependencies": { "@corbits/agent-directory-tools": "workspace:*", @@ -2189,8 +2166,6 @@ "@corbits/inference-settings": ["@corbits/inference-settings@workspace:packages/inference-settings"], - "@corbits/insights": ["@corbits/insights@workspace:packages/insights"], - "@corbits/interaction-tools": ["@corbits/interaction-tools@workspace:packages/interaction-tools"], "@corbits/jimmy-agent": ["@corbits/jimmy-agent@workspace:packages/jimmy-agent"], @@ -3623,8 +3598,6 @@ "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], diff --git a/packages/insights/LICENSE b/packages/insights/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/insights/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/insights/README.md b/packages/insights/README.md deleted file mode 100644 index bd8e47fa5e..0000000000 --- a/packages/insights/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# @corbits/insights - -Workbench-owned usage sink and tenant insights queries: persists per-turn -token classes from the live inference event stream, exposes -cost/token/activity/run-trace queries. Missing rates and pre-sink history are -explicit absences, never fabricated zeros. - -## `/client` subpath contract - -`@corbits/insights/client` (`src/client.ts`) is the browser-safe half of -this package — formatting, windowing, and empty defaults any UI over -Insights data can share, kept apart from the server-side collector, store, -and routes so a browser bundle never drags in postgres, drizzle, or hono -(see `client.test.ts`, which asserts no such import reaches this module). - -**Owns:** `formatUsd`, `formatCount`, `formatRate`, `durationLabel`, -`tokensLabel`, `usageChromeLabel`, `modelsWithMissingRates` — pure formatting; -`InsightsRange`, `createInsightsWindow`, `activitySeriesForWindow`, -`INSIGHTS_WINDOW_DAYS` — the fixed day-window math every Insights chart -uses; `EMPTY_TOKEN_TOTALS`, `EMPTY_OVERALL_USAGE` — the single empty-state -default so no caller invents its own zero object; and the plain `TokenTotals` -/ `ModelUsage` / `OverallUsage` / `DayActivity` types these operate on. - -**A host injects:** nothing — every export here is a pure function or a -constant. A host supplies its own fetch/schema layer (arktype-validated -response parsing, its own route paths) and passes the resulting data -through these helpers; the package has no notion of a hub route or a -tenant. - -**Never imports:** no `postgres`, `drizzle-orm`, or `hono` (those stay in -`collector.ts` / `pg-store.ts` / `routes.ts`); no app-specific fetch client -or `apps/web` state — a host's `insights-api.ts` imports from here, never -the reverse. - -## Key modules - -- `collector.ts` — persists per-turn token classes from the live - inference event stream into `pg-store.ts`. -- `queries.ts` — cost/token/activity/run-trace queries a hub route - serves. -- `trace-reader.ts` — reconstructs a single run's trace for the - run-detail view. -- `pricing.ts` — model rate lookups; a missing rate stays an explicit - absence, never a fabricated zero. -- `routes.ts` — the tenant-scoped Hono routes wrapping the above. - `/usage`, `/activity`, and `/tools` roll up the requested tenant's whole - descendant subtree when a `db` handle is wired (see `resolveScope`) — a - workbench tenant with no children stays a single-tenant view, and its - workspace parent aggregates every child workbench, at this query layer - rather than one browser fetch per workbench. `/scope` is the read-only - counterpart: a tenant's own identity, its parent (if any), and the - sibling workbenches to switch between — what a caller reads to build a - workbench/aggregate switcher. -- `schema.ts` / `migrations.ts` — the usage-sink tables, siloed in their - own Postgres schema with a package-owned migration ledger. - -## Tests - -``` -cd packages/insights && bun test -``` - -`test/migrations.test.ts` and the run-trace suites need a real database: -`DATABASE_URL=postgres://localhost:5432/workbench_e2e bun test`. diff --git a/packages/insights/package.json b/packages/insights/package.json deleted file mode 100644 index 43ce5ba48a..0000000000 --- a/packages/insights/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@corbits/insights", - "private": true, - "description": "Workbench-owned usage sink and tenant insights queries: persists per-turn token classes from the live inference event stream, exposes cost/token/activity/run-trace queries. Missing rates and pre-sink history are explicit absences, never fabricated zeros.", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts", - "./client": "./src/client.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", - "@corbits/provider-pricing": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/log": "0.3.0", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/insights/src/client.test.ts b/packages/insights/src/client.test.ts deleted file mode 100644 index c0671bca87..0000000000 --- a/packages/insights/src/client.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - activitySeriesForWindow, - createInsightsWindow, - EMPTY_OVERALL_USAGE, - formatCount, - formatUsd, - INSIGHTS_WINDOW_DAYS, - modelsWithMissingRates, - modelsWithUnreportedTokens, - tokensLabel, - topModelsByCost, - usageChromeLabel, -} from "./client"; - -// Fixed clock so range math is deterministic regardless of suite time. -const NOW = new Date("2026-01-15T18:00:00.000Z"); - -describe("createInsightsWindow", () => { - test("defaults to a 7-day window ending at now", () => { - const range = createInsightsWindow(undefined, NOW); - expect(INSIGHTS_WINDOW_DAYS).toBe(7); - expect(range.to).toBe("2026-01-15T18:00:00.000Z"); - expect(range.from).toBe("2026-01-08T18:00:00.000Z"); - }); - - test("is stable for the same now input", () => { - expect(createInsightsWindow(7, NOW)).toEqual(createInsightsWindow(7, NOW)); - }); -}); - -describe("empty usage defaults", () => { - test("EMPTY_OVERALL_USAGE is zero metrics, not null cost", () => { - expect(EMPTY_OVERALL_USAGE.turns).toBe(0); - expect(EMPTY_OVERALL_USAGE.costUsd).toBe(0); - expect(EMPTY_OVERALL_USAGE.byModel).toEqual([]); - expect(EMPTY_OVERALL_USAGE.tokens).toEqual({ - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }); - // Formatters must render zeros, never em-dash / NaN for empty spend. - expect(formatUsd(EMPTY_OVERALL_USAGE.costUsd)).toBe("$0.00"); - expect(formatCount(EMPTY_OVERALL_USAGE.turns)).toBe("0"); - expect(formatCount(EMPTY_OVERALL_USAGE.tokens.total)).toBe("0"); - }); - - test("activitySeriesForWindow pads empty sink to zero day series", () => { - const range = createInsightsWindow(INSIGHTS_WINDOW_DAYS, NOW); - const series = activitySeriesForWindow([], range); - expect(series).toHaveLength(INSIGHTS_WINDOW_DAYS); - expect(series.every((d) => d.turns === 0 && d.tokens === 0)).toBe(true); - expect(series.map((d) => d.day)).toEqual([ - "2026-01-09", - "2026-01-10", - "2026-01-11", - "2026-01-12", - "2026-01-13", - "2026-01-14", - "2026-01-15", - ]); - }); - - test("activitySeriesForWindow preserves nonzero day counts", () => { - const range = createInsightsWindow(INSIGHTS_WINDOW_DAYS, NOW); - const series = activitySeriesForWindow( - [ - { - day: "2026-01-14", - turns: 3, - tokens: 900, - byModel: [{ model: "claude-sonnet", tokens: 900, costUsd: 1.2 }], - }, - { day: "2026-01-15", turns: 1, tokens: 100, byModel: [] }, - ], - range, - ); - expect(series).toHaveLength(7); - expect(series.find((d) => d.day === "2026-01-14")).toEqual({ - day: "2026-01-14", - turns: 3, - tokens: 900, - byModel: [{ model: "claude-sonnet", tokens: 900, costUsd: 1.2 }], - }); - expect(series.find((d) => d.day === "2026-01-15")).toEqual({ - day: "2026-01-15", - turns: 1, - tokens: 100, - byModel: [], - }); - expect(series.find((d) => d.day === "2026-01-10")?.turns).toBe(0); - expect(series.find((d) => d.day === "2026-01-10")?.byModel).toEqual([]); - }); - - test("topModelsByCost ranks by total cost across the window, capped at the limit", () => { - const days = [ - { - day: "2026-01-14", - turns: 2, - tokens: 900, - byModel: [ - { model: "cheap-model", tokens: 500, costUsd: 0.1 }, - { model: "pricey-model", tokens: 400, costUsd: 5 }, - ], - }, - { - day: "2026-01-15", - turns: 1, - tokens: 100, - byModel: [{ model: "cheap-model", tokens: 100, costUsd: 0.02 }], - }, - ]; - expect(topModelsByCost(days, 1)).toEqual(["pricey-model"]); - expect(topModelsByCost(days)).toEqual(["pricey-model", "cheap-model"]); - }); - - test("formatUsd keeps em-dash for unknown rates, not for zero", () => { - expect(formatUsd(null)).toBe("—"); - expect(formatUsd(0)).toBe("$0.00"); - expect(formatUsd(1.5)).toBe("$1.50"); - }); -}); - -// CL-6877: chrome must never invent "0 tok". Zero spend is `$0.00`; zero -// tokens omit the tok segment entirely. tokensLabel and usageChromeLabel -// are the single consumer-safe path sidebar / insights share. -describe("CL-6877 usage chrome labels", () => { - const zeroTokens = { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }; - - const someTokens = { - input: 1000, - cacheRead: 200, - cacheWrite: 0, - output: 50, - thinking: 0, - }; - - test("tokensLabel omits null and zero totals — never returns 0 tok", () => { - expect(tokensLabel(null)).toBeUndefined(); - expect(tokensLabel(zeroTokens)).toBeUndefined(); - expect(tokensLabel(EMPTY_OVERALL_USAGE.tokens)).toBeUndefined(); - expect(tokensLabel(someTokens)).toBe("1,250 tok"); - }); - - test("usageChromeLabel is cost-only at zero tokens, cost · tok when nonzero", () => { - expect(usageChromeLabel({ costUsd: 0, tokens: zeroTokens })).toBe("$0.00"); - expect( - usageChromeLabel({ costUsd: 0, tokens: EMPTY_OVERALL_USAGE.tokens }), - ).toBe("$0.00"); - expect(usageChromeLabel({ costUsd: 0, tokens: null })).toBe("$0.00"); - expect(usageChromeLabel({ costUsd: 1.5, tokens: someTokens })).toBe( - "$1.50 · 1,250 tok", - ); - expect(usageChromeLabel({ costUsd: null, tokens: someTokens })).toBe( - "— · 1,250 tok", - ); - expect(usageChromeLabel(EMPTY_OVERALL_USAGE)).not.toContain("0 tok"); - }); -}); - -describe("CL-6659 unreported tokens vs missing rates", () => { - const zeroTokens = { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }; - - test("modelsWithUnreportedTokens names models that recorded turns but no token counts", () => { - expect( - modelsWithUnreportedTokens({ - turns: 2, - tokens: zeroTokens, - costUsd: null, - byModel: [ - { - model: "qwen3:latest", - turns: 2, - tokens: zeroTokens, - costUsd: null, - }, - ], - }), - ).toEqual(["qwen3:latest"]); - expect(modelsWithUnreportedTokens(EMPTY_OVERALL_USAGE)).toEqual([]); - }); - - test("modelsWithMissingRates still requires tokens so unreported turns are not labeled as unknown rates", () => { - expect( - modelsWithMissingRates({ - turns: 1, - tokens: zeroTokens, - costUsd: null, - byModel: [ - { - model: "qwen3:latest", - turns: 1, - tokens: zeroTokens, - costUsd: null, - }, - ], - }), - ).toEqual([]); - }); - - test("usageChromeLabel is an em-dash when cost is unknown and tokens were not reported", () => { - expect(usageChromeLabel({ costUsd: null, tokens: zeroTokens })).toBe("—"); - }); -}); diff --git a/packages/insights/src/client.ts b/packages/insights/src/client.ts deleted file mode 100644 index 1da3eeacb6..0000000000 --- a/packages/insights/src/client.ts +++ /dev/null @@ -1,242 +0,0 @@ -// Browser-safe Insights domain helpers: formatting, windowing, and empty -// defaults shared by any UI over packages/insights data. No postgres, -// drizzle, or hono import reaches this module — see client.test.ts. -// -// Absent *usage* (empty sink / no tenant) is zero metrics and an empty day -// series — see EMPTY_OVERALL_USAGE / activitySeriesForWindow. Null cost/rate -// still means "rate unknown" when turns exist; that is not coerced to zero. - -export type TokenTotals = { - readonly input: number; - readonly cacheRead: number; - readonly cacheWrite: number; - readonly output: number; - readonly thinking: number; - readonly total: number; -}; - -export type ModelUsage = { - readonly model: string; - readonly turns: number; - readonly tokens: TokenTotals; - readonly costUsd: number | null; -}; - -export type OverallUsage = { - readonly turns: number; - readonly tokens: TokenTotals; - readonly costUsd: number | null; - readonly byModel: readonly ModelUsage[]; -}; - -export type ModelDayUsage = { - readonly model: string; - readonly tokens: number; - readonly costUsd: number | null; -}; - -export type DayActivity = { - readonly day: string; - readonly turns: number; - readonly tokens: number; - readonly byModel: readonly ModelDayUsage[]; -}; - -/** One workbench's usage totals, ranked for the global landing's - * activity-by-workbench chart. */ -export type WorkbenchUsage = { - readonly tenantId: string; - readonly name: string; - readonly turns: number; - readonly tokens: TokenTotals; - readonly costUsd: number | null; -}; - -/** Stable ISO from/to shared by usage, activity, and tools path builders. */ -export type InsightsRange = { - readonly from: string; - readonly to: string; -}; - -/** Default Insights landing window (honest 7-day KPIs and charts). */ -export const INSIGHTS_WINDOW_DAYS = 7; - -/** Zero token totals — empty sink / no usage recorded. */ -export const EMPTY_TOKEN_TOTALS: TokenTotals = { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, -}; - -/** - * Single empty-usage default for the Insights client. Use when there is no - * tenant or the sink has no rows — never invent demo peaks. API errors must - * surface as load failures, not this zero object. `costUsd` is 0 (no spend), - * not null (unknown rate). - */ -export const EMPTY_OVERALL_USAGE: OverallUsage = { - turns: 0, - tokens: { ...EMPTY_TOKEN_TOTALS }, - costUsd: 0, - byModel: [], -}; - -/** - * Build a fixed [from, to] window ending at `now`. Pass an explicit `now` - * (and keep the result) so React query keys stay stable across rerenders. - */ -export function createInsightsWindow( - days: number = INSIGHTS_WINDOW_DAYS, - now: Date = new Date(), -): InsightsRange { - const to = now.toISOString(); - const from = new Date( - now.getTime() - days * 24 * 60 * 60 * 1000, - ).toISOString(); - return { from, to }; -} - -/** - * Pad sparse (or empty) activity into a fixed day series ending on `range.to`. - * Missing days are zero turns/tokens so charts stay shaped and never invent - * nonzero peaks. Overlay preserves any real day counts from the sink. - */ -export function activitySeriesForWindow( - days: readonly DayActivity[], - range: InsightsRange, - windowDays: number = INSIGHTS_WINDOW_DAYS, -): DayActivity[] { - const end = new Date(range.to); - const endUTC = Date.UTC( - end.getUTCFullYear(), - end.getUTCMonth(), - end.getUTCDate(), - ); - if (Number.isNaN(endUTC) || windowDays <= 0) return []; - - const dayMs = 86_400_000; - const byDay = new Map(days.map((d) => [d.day, d] as const)); - const out: DayActivity[] = []; - for (let offset = windowDays - 1; offset >= 0; offset -= 1) { - const key = new Date(endUTC - offset * dayMs).toISOString().slice(0, 10); - const hit = byDay.get(key); - out.push(hit ?? { day: key, turns: 0, tokens: 0, byModel: [] }); - } - return out; -} - -/** - * Top N models by total cost across `days` (ties broken by tokens), for the - * global landing's tokens/cost-over-time chart — same "≤5 series, sum the - * rest" rule `TimeSeriesChart` itself documents, applied before the data - * reaches it rather than inside it. - */ -export function topModelsByCost( - days: readonly DayActivity[], - limit = 5, -): readonly string[] { - const totals = new Map(); - for (const day of days) { - for (const entry of day.byModel) { - const current = totals.get(entry.model) ?? { cost: 0, tokens: 0 }; - totals.set(entry.model, { - cost: current.cost + (entry.costUsd ?? 0), - tokens: current.tokens + entry.tokens, - }); - } - } - return [...totals.entries()] - .sort(([, a], [, b]) => b.cost - a.cost || b.tokens - a.tokens) - .slice(0, limit) - .map(([model]) => model); -} - -/** - * Format USD cost. Null/undefined means rate unknown → em-dash. - * Zero is real empty spend → `$0.00` (do not treat as absent). - */ -export function formatUsd(value: number | null | undefined): string { - if (value === null || value === undefined) return "—"; - if (value < 0.01 && value > 0) return `$${value.toFixed(4)}`; - return `$${value.toFixed(2)}`; -} - -/** Compact integer; null/undefined → em-dash. */ -export function formatCount(value: number | null | undefined): string { - if (value === null || value === undefined) return "—"; - return value.toLocaleString(); -} - -/** Rate 0–1 as percent; null → em-dash. */ -export function formatRate(value: number | null | undefined): string { - if (value === null || value === undefined) return "—"; - return `${Math.round(value * 100)}%`; -} - -export function durationLabel(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - return `${(ms / 60_000).toFixed(1)}m`; -} - -export function tokensLabel( - tokens: { - readonly input: number; - readonly cacheRead: number; - readonly cacheWrite: number; - readonly output: number; - readonly thinking: number; - } | null, -): string | undefined { - if (tokens === null) return undefined; - const total = - tokens.input + - tokens.cacheRead + - tokens.cacheWrite + - tokens.output + - tokens.thinking; - // Zero total is real empty usage — omit chrome entirely. Never invent - // "0 tok" (CL-6877 / DESIGN empty-zero). - if (total === 0) return undefined; - return `${total.toLocaleString()} tok`; -} - -/** - * Consumer-safe cost · tokens chrome for sidebar / compact usage lines. - * Zero spend stays `$0.00`; zero or absent tokens omit the tok segment — - * never `$0.00 · 0 tok`. Callers must not invent a `"0 tok"` fallback. - */ -export function usageChromeLabel(usage: { - readonly costUsd: number | null | undefined; - readonly tokens: { - readonly input: number; - readonly cacheRead: number; - readonly cacheWrite: number; - readonly output: number; - readonly thinking: number; - } | null; -}): string { - const cost = formatUsd(usage.costUsd); - const tok = tokensLabel(usage.tokens); - if (usage.costUsd === null && tok === undefined) return "—"; - return tok === undefined ? cost : `${cost} · ${tok}`; -} - -/** Models with tokens but no known rate (costUsd null). */ -export function modelsWithMissingRates(usage: OverallUsage): readonly string[] { - return usage.byModel - .filter((m) => m.costUsd === null && m.tokens.total > 0) - .map((m) => m.model); -} - -/** Models that recorded turns but no token counts — not the same as $0 spend. */ -export function modelsWithUnreportedTokens( - usage: OverallUsage, -): readonly string[] { - return usage.byModel - .filter((m) => m.turns > 0 && m.tokens.total === 0) - .map((m) => m.model); -} diff --git a/packages/insights/src/collector.test.ts b/packages/insights/src/collector.test.ts deleted file mode 100644 index 19faf2de27..0000000000 --- a/packages/insights/src/collector.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createUsageSink } from "./collector"; -import { createMemoryUsageStore } from "./store"; - -describe("createUsageSink", () => { - test("inserts a usage event", async () => { - const store = createMemoryUsageStore(); - let n = 0; - const sink = createUsageSink({ - store, - generateId: () => `id-${++n}`, - }); - - const status = await sink.handle({ - turnId: "turn-1", - tenantId: "tenant-acme", - sessionId: "session-1", - provider: "anthropic", - model: "claude-sonnet", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 50, - thinking: 0, - }, - reportedCostUsd: 0.00125, - }); - - expect(status).toBe("inserted"); - const rows = await store.listUsageByTenants(["tenant-acme"]); - expect(rows).toHaveLength(1); - expect(rows[0]?.turnId).toBe("turn-1"); - expect(rows[0]?.provider).toBe("anthropic"); - expect(rows[0]?.tokens.input).toBe(100); - expect(rows[0]?.reportedCostUsd).toBe(0.00125); - }); - - test("duplicate turnId is a no-op (restart-safe)", async () => { - const store = createMemoryUsageStore(); - let n = 0; - const sink = createUsageSink({ - store, - generateId: () => `id-${++n}`, - }); - - const event = { - turnId: "turn-dup", - tenantId: "tenant-acme", - sessionId: "session-1", - model: "claude-sonnet", - tokens: { - input: 10, - cacheRead: 0, - cacheWrite: 0, - output: 5, - thinking: 0, - }, - }; - - expect(await sink.handle(event)).toBe("inserted"); - expect(await sink.handle(event)).toBe("duplicate"); - const rows = await store.listUsageByTenants(["tenant-acme"]); - expect(rows).toHaveLength(1); - }); -}); diff --git a/packages/insights/src/collector.ts b/packages/insights/src/collector.ts deleted file mode 100644 index 2293d435d3..0000000000 --- a/packages/insights/src/collector.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { TokenClasses } from "./pricing"; -import type { UsageStore } from "./store"; - -/** - * A usage event as emitted by the live inference stream. The platform - * event-collector drops these; this sink is the product-side consumer. - * The shape is deliberately narrow so it can be upstreamed to Interchange - * later without dragging workbench product fields along. - */ -export type UsageEvent = { - readonly turnId: string; - readonly tenantId: string; - readonly sessionId: string; - readonly provider?: string; - readonly model: string; - readonly tokens: TokenClasses; - readonly reportedCostUsd?: number; - readonly recordedAt?: Date; -}; - -export type UsageSinkDeps = { - store: UsageStore; - /** Id generator — inject `generateId` from @intx/hub-common at the mount. */ - generateId: () => string; -}; - -/** - * Persist usage events idempotently by turnId. Restart-safe: replaying - * an already-recorded turn is a no-op, never a double count. - */ -export function createUsageSink(deps: UsageSinkDeps) { - return { - async handle(event: UsageEvent): Promise<"inserted" | "duplicate"> { - const baseInsert = { - id: deps.generateId(), - tenantId: event.tenantId, - sessionId: event.sessionId, - turnId: event.turnId, - model: event.model, - tokens: event.tokens, - ...(event.provider === undefined ? {} : { provider: event.provider }), - ...(event.reportedCostUsd === undefined - ? {} - : { reportedCostUsd: event.reportedCostUsd }), - }; - const result = await deps.store.insertUsage( - event.recordedAt === undefined - ? baseInsert - : { ...baseInsert, recordedAt: event.recordedAt }, - ); - return result === null ? "duplicate" : "inserted"; - }, - }; -} - -export type UsageSink = ReturnType; diff --git a/packages/insights/src/index.ts b/packages/insights/src/index.ts deleted file mode 100644 index c136cb708a..0000000000 --- a/packages/insights/src/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -export { - applyInsightsMigrations, - insightsMigrations, - type ApplyInsightsMigrationsReport, - type InsightsMigration, -} from "./migrations"; -export { - modelPrice, - turnLatency, - usageTurn, - type ModelPriceRow, - type TurnLatencyRow, - type UsageTurnRow, -} from "./schema"; -export { - computeCost, - totalTokens, - type CostBreakdown, - type TokenClasses, - type TokenRates, -} from "./pricing"; -export { - createMemoryUsageStore, - type InsertUsageInput, - type ModelPriceRecord, - type UsageStore, - type UsageTurnRecord, -} from "./store"; -export { createPostgresUsageStore } from "./pg-store"; -export { - createUsageSink, - type UsageEvent, - type UsageSink, - type UsageSinkDeps, -} from "./collector"; -export { - createMemoryTurnLatencyStore, - type InsertTurnLatencyInput, - type TurnLatencyRecord, - type TurnLatencyStore, -} from "./latency-store"; -export { createPostgresTurnLatencyStore } from "./latency-pg-store"; -export { - createTurnLatencyTracker, - type LatencyStageEvent, - type TurnLatencyTracker, - type TurnLatencyTrackerDeps, -} from "./latency-tracker"; -export { - activityByDay, - emptyOverallUsageSummary, - emptyToolCallReader, - percentile, - summarizeLatency, - summarizeUsage, - summarizeUsageByTenant, - teamSpaceWorkbenchRows, - type DayActivity, - type LatencyStageStat, - type LatencySummary, - type ModelDayUsage, - type ModelUsageSummary, - type OverallUsageSummary, - type RunTrace, - type RunTraceReader, - type RunTraceSpan, - type TokenTotals, - type ToolCallReader, - type ToolCallSummary, - type WorkbenchUsage, -} from "./queries"; -export { createInsightsRoutes, type CreateInsightsRoutesDeps } from "./routes"; -export { createDrizzleRunTraceReader } from "./trace-reader"; -export { - withTurnPartPersistGuard, - turnPartPersistFailures, -} from "./turn-part-write-guard"; -export { - createDrizzleTurnTextSnapshotReader, - snapshotTextFromParts, - type TurnTextSnapshotReader, -} from "./turn-text-snapshot"; diff --git a/packages/insights/src/latency-pg-store.ts b/packages/insights/src/latency-pg-store.ts deleted file mode 100644 index e4106f9cc8..0000000000 --- a/packages/insights/src/latency-pg-store.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Postgres-backed TurnLatencyStore against the package-owned turn_latency -// table. Created after applyInsightsMigrations on the same URL — mirrors -// pg-store.ts's shape for the same reasons (see that file's header). - -import postgres from "postgres"; - -import type { - InsertTurnLatencyInput, - TurnLatencyRecord, - TurnLatencyStore, -} from "./latency-store"; - -type Sql = ReturnType; - -type LatencyRow = { - id: string; - tenant_id: string; - session_id: string; - message_id: string; - message_run_id: string; - status: string; - received_at: Date; - reactor_start_at: Date | null; - inference_start_at: Date | null; - first_token_at: Date | null; - reply_posted_at: Date; -}; - -function toRecord(row: LatencyRow): TurnLatencyRecord { - return { - id: row.id, - tenantId: row.tenant_id, - sessionId: row.session_id, - messageId: row.message_id, - messageRunId: row.message_run_id, - status: row.status === "failed" ? "failed" : "completed", - receivedAt: row.received_at, - reactorStartAt: row.reactor_start_at, - inferenceStartAt: row.inference_start_at, - firstTokenAt: row.first_token_at, - replyPostedAt: row.reply_posted_at, - }; -} - -/** - * Open a TurnLatencyStore on an already-migrated Postgres URL. Caller - * owns connection lifetime via the returned `close` handle. - */ -export function createPostgresTurnLatencyStore(databaseUrl: string): { - store: TurnLatencyStore; - close: () => Promise; -} { - const sql: Sql = postgres(databaseUrl, { - max: 4, - onnotice: () => undefined, - }); - - const store: TurnLatencyStore = { - async insertLatency(input: InsertTurnLatencyInput) { - try { - const rows = await sql` - INSERT INTO insights.turn_latency ( - id, tenant_id, session_id, message_id, message_run_id, status, - received_at, reactor_start_at, inference_start_at, - first_token_at, reply_posted_at - ) VALUES ( - ${input.id}, ${input.tenantId}, ${input.sessionId}, - ${input.messageId}, ${input.messageRunId}, ${input.status}, - ${input.receivedAt}, ${input.reactorStartAt}, - ${input.inferenceStartAt}, ${input.firstTokenAt}, - ${input.replyPostedAt} - ) - ON CONFLICT (message_run_id) DO NOTHING - RETURNING * - `; - const row = rows[0]; - return row === undefined ? null : toRecord(row); - } catch (error) { - if (error instanceof Error && /unique|duplicate/i.test(error.message)) { - return null; - } - throw error; - } - }, - - async listLatencyByTenants(tenantIds, opts) { - if (tenantIds.length === 0) return []; - const from = opts?.from; - const to = opts?.to; - const rows = await sql` - SELECT * FROM insights.turn_latency - WHERE tenant_id = ANY(${[...tenantIds]}) - AND (${from ?? null}::timestamptz IS NULL OR received_at >= ${from ?? null}) - AND (${to ?? null}::timestamptz IS NULL OR received_at <= ${to ?? null}) - ORDER BY received_at ASC - `; - return rows.map(toRecord); - }, - }; - - return { - store, - close: async () => { - await sql.end({ timeout: 5 }); - }, - }; -} diff --git a/packages/insights/src/latency-queries.test.ts b/packages/insights/src/latency-queries.test.ts deleted file mode 100644 index eb7f7eedd4..0000000000 --- a/packages/insights/src/latency-queries.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createMemoryTurnLatencyStore } from "./latency-store"; -import { percentile, summarizeLatency } from "./queries"; - -const BASE = new Date("2026-08-01T00:00:00Z").getTime(); - -function ms(offset: number): Date { - return new Date(BASE + offset); -} - -describe("percentile", () => { - test("null on empty input", () => { - expect(percentile([], 0.5)).toBeNull(); - }); - - test("nearest-rank p50/p95 over a sorted array", () => { - const sorted = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; - expect(percentile(sorted, 0.5)).toBe(50); - expect(percentile(sorted, 0.95)).toBe(100); - }); -}); - -describe("summarizeLatency", () => { - test("no rows: every stage is null with zero samples", async () => { - const store = createMemoryTurnLatencyStore(); - const summary = await summarizeLatency(store, ["tenant-acme"]); - expect(summary.total).toEqual({ p50Ms: null, p95Ms: null, samples: 0 }); - expect(summary.toReactorStart).toEqual({ - p50Ms: null, - p95Ms: null, - samples: 0, - }); - }); - - test("a warm-session row (no reactorStartAt) skips that stage only", async () => { - const store = createMemoryTurnLatencyStore(); - await store.insertLatency({ - id: "id-1", - tenantId: "tenant-acme", - sessionId: "s1", - messageId: "m1", - messageRunId: "r1", - status: "completed", - receivedAt: ms(0), - reactorStartAt: null, - inferenceStartAt: ms(500), - firstTokenAt: ms(2_000), - replyPostedAt: ms(4_000), - }); - - const summary = await summarizeLatency(store, ["tenant-acme"]); - expect(summary.toReactorStart.samples).toBe(0); - expect(summary.toInferenceStart.samples).toBe(1); - expect(summary.toInferenceStart.p50Ms).toBe(500); - expect(summary.toFirstToken.p50Ms).toBe(1_500); - expect(summary.toReplyPosted.p50Ms).toBe(2_000); - expect(summary.total.p50Ms).toBe(4_000); - }); - - test("p50/p95 over several rows", async () => { - const store = createMemoryTurnLatencyStore(); - const totals = [1_000, 2_000, 3_000, 4_000, 10_000]; - for (const [i, totalMs] of totals.entries()) { - await store.insertLatency({ - id: `id-${i}`, - tenantId: "tenant-acme", - sessionId: "s1", - messageId: `m${i}`, - messageRunId: `r${i}`, - status: "completed", - receivedAt: ms(0), - reactorStartAt: null, - inferenceStartAt: null, - firstTokenAt: null, - replyPostedAt: ms(totalMs), - }); - } - - const summary = await summarizeLatency(store, ["tenant-acme"]); - expect(summary.total.samples).toBe(5); - expect(summary.total.p50Ms).toBe(3_000); - expect(summary.total.p95Ms).toBe(10_000); - }); - - test("scopes to the requested tenants only", async () => { - const store = createMemoryTurnLatencyStore(); - await store.insertLatency({ - id: "id-a", - tenantId: "workbench-a", - sessionId: "s1", - messageId: "m1", - messageRunId: "r1", - status: "completed", - receivedAt: ms(0), - reactorStartAt: null, - inferenceStartAt: null, - firstTokenAt: null, - replyPostedAt: ms(1_000), - }); - await store.insertLatency({ - id: "id-b", - tenantId: "workbench-b", - sessionId: "s2", - messageId: "m2", - messageRunId: "r2", - status: "completed", - receivedAt: ms(0), - reactorStartAt: null, - inferenceStartAt: null, - firstTokenAt: null, - replyPostedAt: ms(9_000), - }); - - const summary = await summarizeLatency(store, ["workbench-a"]); - expect(summary.total.samples).toBe(1); - expect(summary.total.p50Ms).toBe(1_000); - }); -}); diff --git a/packages/insights/src/latency-store.ts b/packages/insights/src/latency-store.ts deleted file mode 100644 index 8c8d9c1f1b..0000000000 --- a/packages/insights/src/latency-store.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Package-owned store for per-message-run turn latency (CL-6257): the -// wall-clock gap between a message reaching the reactor and its reply -// posting back, broken into the stages a turn actually passes through. -// Mirrors `store.ts`'s UsageStore shape — same idempotent-insert-by-key, -// same in-memory/drizzle split — so the two sinks read the same way at -// every call site that already knows one. - -export type TurnLatencyRecord = { - readonly id: string; - readonly tenantId: string; - readonly sessionId: string; - readonly messageId: string; - readonly messageRunId: string; - readonly status: "completed" | "failed"; - /** Wall-clock the reactor's director dequeued the inbound message. */ - readonly receivedAt: Date; - /** - * When the reactor itself (re)started for this connection. Null on - * every message after the first in a session the reactor kept warm - * for — only a cold start (a fresh session, e.g. the greeting) pays - * this stage, so absence here is the common, honest case, not a gap. - */ - readonly reactorStartAt: Date | null; - readonly inferenceStartAt: Date | null; - readonly firstTokenAt: Date | null; - readonly replyPostedAt: Date; -}; - -export type InsertTurnLatencyInput = { - readonly id: string; - readonly tenantId: string; - readonly sessionId: string; - readonly messageId: string; - readonly messageRunId: string; - readonly status: "completed" | "failed"; - readonly receivedAt: Date; - readonly reactorStartAt: Date | null; - readonly inferenceStartAt: Date | null; - readonly firstTokenAt: Date | null; - readonly replyPostedAt: Date; -}; - -export type TurnLatencyStore = { - /** Insert a message run's latency row. Returns null if messageRunId - * already exists (idempotent — a dispatch retry never double-counts). */ - insertLatency( - input: InsertTurnLatencyInput, - ): Promise; - listLatencyByTenants( - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, - ): Promise; -}; - -/** In-memory store for unit tests and local smoke. */ -export function createMemoryTurnLatencyStore(): TurnLatencyStore { - const rows = new Map(); - const byMessageRunId = new Map(); - - return { - async insertLatency(input) { - if (byMessageRunId.has(input.messageRunId)) return null; - const record: TurnLatencyRecord = { ...input }; - rows.set(record.id, record); - byMessageRunId.set(record.messageRunId, record.id); - return record; - }, - async listLatencyByTenants(tenantIds, opts) { - const scope = new Set(tenantIds); - return [...rows.values()] - .filter((r) => scope.has(r.tenantId)) - .filter((r) => - opts?.from === undefined ? true : r.receivedAt >= opts.from, - ) - .filter((r) => - opts?.to === undefined ? true : r.receivedAt <= opts.to, - ) - .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); - }, - }; -} diff --git a/packages/insights/src/latency-tracker.test.ts b/packages/insights/src/latency-tracker.test.ts deleted file mode 100644 index 1e16a3f640..0000000000 --- a/packages/insights/src/latency-tracker.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createTurnLatencyTracker } from "./latency-tracker"; -import { createMemoryTurnLatencyStore } from "./latency-store"; - -function fakeClock(start: number) { - let t = start; - return { - now: () => t, - advance: (ms: number) => { - t += ms; - }, - }; -} - -describe("createTurnLatencyTracker", () => { - test("records every stage for a cold-start message run", async () => { - const store = createMemoryTurnLatencyStore(); - const clock = fakeClock(1_000); - let n = 0; - const tracker = createTurnLatencyTracker({ - store, - generateId: () => `id-${++n}`, - now: clock.now, - }); - - tracker.onSessionCreate("agent-1", "tenant-acme", "session-1"); - - tracker.onEvent("agent-1", { type: "reactor.start" }); - clock.advance(30_000); // cold-start reactor boot - tracker.onEvent("agent-1", { - type: "message.run.started", - data: { messageId: "msg-1", messageRunId: "run-1", receivedAt: 1_000 }, - }); - clock.advance(1_000); - tracker.onEvent("agent-1", { type: "inference.start" }); - clock.advance(2_000); - tracker.onEvent("agent-1", { type: "inference.text.delta" }); - clock.advance(500); - // A second delta must not move firstTokenAt. - tracker.onEvent("agent-1", { type: "inference.text.delta" }); - clock.advance(1_500); - tracker.onEvent("agent-1", { - type: "message.run.ended", - data: { messageRunId: "run-1", messageId: "msg-1", status: "completed" }, - }); - - const rows = await store.listLatencyByTenants(["tenant-acme"]); - expect(rows).toHaveLength(1); - const row = rows.at(0); - if (row === undefined) throw new Error("expected a row"); - expect(row.tenantId).toBe("tenant-acme"); - expect(row.sessionId).toBe("session-1"); - expect(row.messageRunId).toBe("run-1"); - expect(row.status).toBe("completed"); - expect(row.receivedAt.getTime()).toBe(1_000); - // reactor.start observed at t=1000, then 30s cold-start boot plus the - // 1s before inference.start fires: reactorStartAt -> inferenceStartAt - // spans both. - const { reactorStartAt, inferenceStartAt, firstTokenAt, replyPostedAt } = - row; - if ( - reactorStartAt === null || - inferenceStartAt === null || - firstTokenAt === null - ) { - throw new Error("expected every stage timestamp to be recorded"); - } - expect(inferenceStartAt.getTime() - reactorStartAt.getTime()).toBe(31_000); - expect(firstTokenAt.getTime() - inferenceStartAt.getTime()).toBe(2_000); - expect(replyPostedAt.getTime() - firstTokenAt.getTime()).toBe(2_000); - }); - - test("reactorStartAt is null on a warm session's later message", async () => { - const store = createMemoryTurnLatencyStore(); - const clock = fakeClock(0); - let n = 0; - const tracker = createTurnLatencyTracker({ - store, - generateId: () => `id-${++n}`, - now: clock.now, - }); - - tracker.onSessionCreate("agent-1", "tenant-acme", "session-1"); - // No reactor.start this time — the reactor was already running. - tracker.onEvent("agent-1", { - type: "message.run.started", - data: { messageId: "msg-2", messageRunId: "run-2", receivedAt: 0 }, - }); - tracker.onEvent("agent-1", { type: "inference.start" }); - tracker.onEvent("agent-1", { type: "inference.text.delta" }); - tracker.onEvent("agent-1", { - type: "message.run.ended", - data: { messageRunId: "run-2", messageId: "msg-2", status: "completed" }, - }); - - const rows = await store.listLatencyByTenants(["tenant-acme"]); - expect(rows[0]?.reactorStartAt).toBeNull(); - }); - - test("ignores events for a session that was never created", async () => { - const store = createMemoryTurnLatencyStore(); - const tracker = createTurnLatencyTracker({ - store, - generateId: () => "id-1", - }); - - tracker.onEvent("ghost-agent", { - type: "message.run.started", - data: { messageId: "m", messageRunId: "r", receivedAt: Date.now() }, - }); - tracker.onEvent("ghost-agent", { - type: "message.run.ended", - data: { messageRunId: "r", messageId: "m", status: "completed" }, - }); - - const rows = await store.listLatencyByTenants(["tenant-acme"]); - expect(rows).toHaveLength(0); - }); - - test("onSessionEnd drops in-flight state without persisting", async () => { - const store = createMemoryTurnLatencyStore(); - const tracker = createTurnLatencyTracker({ - store, - generateId: () => "id-1", - }); - - tracker.onSessionCreate("agent-1", "tenant-acme", "session-1"); - tracker.onEvent("agent-1", { - type: "message.run.started", - data: { messageId: "m", messageRunId: "r", receivedAt: Date.now() }, - }); - tracker.onSessionEnd("agent-1"); - tracker.onEvent("agent-1", { - type: "message.run.ended", - data: { messageRunId: "r", messageId: "m", status: "completed" }, - }); - - const rows = await store.listLatencyByTenants(["tenant-acme"]); - expect(rows).toHaveLength(0); - }); -}); diff --git a/packages/insights/src/latency-tracker.ts b/packages/insights/src/latency-tracker.ts deleted file mode 100644 index 14a224d06b..0000000000 --- a/packages/insights/src/latency-tracker.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { TurnLatencyStore } from "./latency-store"; - -// Stage timing for one message run (CL-6257): message-received → -// reactor.start → inference.start → first-token → reply-posted. The -// vendored InferenceEvent stream (`@intx/types/runtime`, see VENDORED.md) -// already carries every timestamp this needs — `message.run.started`'s -// own `data.receivedAt` for the first stage, wall-clock-at-observation for -// the rest — but the vendored event collector -// (`@intx/hub-sessions#createEventCollector`) never persists these event -// types (its `default:` branch drops them) and is not ours to edit. This -// tracker is the product-side seam instead: a structural subset of -// InferenceEvent so it stays decoupled from the vendor type import, wired -// by wrapping the `EventCollectorRegistry` object at its one composition -// root (apps/hub/src/index.ts) the same way `createUsageSink`'s `onUsage` -// forward already is. -type MessageRunStartedData = { - readonly messageId: string; - readonly messageRunId: string; - readonly receivedAt: number; -}; - -type MessageRunEndedData = { - readonly messageRunId: string; - readonly messageId: string; - readonly status: "completed" | "failed"; -}; - -/** - * Every InferenceEvent variant carries a `data` field (even the empty-object - * ones, e.g. `reactor.start`), so this loose shape is structurally - * satisfied by the real vendor union without importing it — `onEvent` - * narrows `data` itself once `type` is checked at runtime, the same way a - * `switch` over the real union's discriminant would. - */ -export type LatencyStageEvent = { - readonly type: string; - readonly data?: unknown; -}; - -type InFlightMessageRun = { - messageId: string; - messageRunId: string; - receivedAt: number; - reactorStartAt: number | null; - inferenceStartAt: number | null; - firstTokenAt: number | null; -}; - -export type TurnLatencyTrackerDeps = { - store: TurnLatencyStore; - generateId: () => string; - /** Injectable clock — tests supply a fake one. */ - now?: () => number; -}; - -export type TurnLatencyTracker = { - /** Mirrors `EventCollectorRegistry.create`'s identity args — the only - * place tenantId/sessionId are known for a given agentAddress. */ - onSessionCreate( - agentAddress: string, - tenantId: string, - sessionId: string, - ): void; - onSessionEnd(agentAddress: string): void; - onEvent(agentAddress: string, event: LatencyStageEvent): void; -}; - -/** - * Observes the same InferenceEvent stream the event collector does, - * without touching it, and persists one row per message run once it - * closes. A `reactor.start` seen before its message run has opened yet - * (the ordinary case: the reactor starts, then dequeues the message that - * woke it) is stashed per agentAddress and attached to the next run that - * opens — `reactorStartAt` only ever describes a cold start, so it is - * correctly null on every later message in a session the reactor kept - * warm for. - */ -export function createTurnLatencyTracker( - deps: TurnLatencyTrackerDeps, -): TurnLatencyTracker { - const now = deps.now ?? Date.now; - const sessions = new Map(); - const active = new Map(); - const pendingReactorStart = new Map(); - - function onSessionCreate( - agentAddress: string, - tenantId: string, - sessionId: string, - ): void { - sessions.set(agentAddress, { tenantId, sessionId }); - } - - function onSessionEnd(agentAddress: string): void { - sessions.delete(agentAddress); - active.delete(agentAddress); - pendingReactorStart.delete(agentAddress); - } - - function onEvent(agentAddress: string, event: LatencyStageEvent): void { - const session = sessions.get(agentAddress); - if (session === undefined) return; - - switch (event.type) { - case "reactor.start": { - const inFlight = active.get(agentAddress); - if (inFlight !== undefined) { - if (inFlight.reactorStartAt === null) inFlight.reactorStartAt = now(); - } else { - pendingReactorStart.set(agentAddress, now()); - } - return; - } - case "message.run.started": { - const data = event.data as MessageRunStartedData; - const pending = pendingReactorStart.get(agentAddress) ?? null; - pendingReactorStart.delete(agentAddress); - active.set(agentAddress, { - messageId: data.messageId, - messageRunId: data.messageRunId, - receivedAt: data.receivedAt, - reactorStartAt: pending, - inferenceStartAt: null, - firstTokenAt: null, - }); - return; - } - case "inference.start": { - const inFlight = active.get(agentAddress); - if (inFlight !== undefined && inFlight.inferenceStartAt === null) { - inFlight.inferenceStartAt = now(); - } - return; - } - case "inference.text.delta": { - const inFlight = active.get(agentAddress); - if (inFlight !== undefined && inFlight.firstTokenAt === null) { - inFlight.firstTokenAt = now(); - } - return; - } - case "message.run.ended": { - const data = event.data as MessageRunEndedData; - const inFlight = active.get(agentAddress); - if ( - inFlight === undefined || - inFlight.messageRunId !== data.messageRunId - ) { - return; - } - active.delete(agentAddress); - const replyPostedAt = now(); - void deps.store.insertLatency({ - id: deps.generateId(), - tenantId: session.tenantId, - sessionId: session.sessionId, - messageId: inFlight.messageId, - messageRunId: inFlight.messageRunId, - status: data.status, - receivedAt: new Date(inFlight.receivedAt), - reactorStartAt: - inFlight.reactorStartAt === null - ? null - : new Date(inFlight.reactorStartAt), - inferenceStartAt: - inFlight.inferenceStartAt === null - ? null - : new Date(inFlight.inferenceStartAt), - firstTokenAt: - inFlight.firstTokenAt === null - ? null - : new Date(inFlight.firstTokenAt), - replyPostedAt: new Date(replyPostedAt), - }); - return; - } - default: - return; - } - } - - return { onSessionCreate, onSessionEnd, onEvent }; -} diff --git a/packages/insights/src/migrations.ts b/packages/insights/src/migrations.ts deleted file mode 100644 index 1db8ff36d0..0000000000 --- a/packages/insights/src/migrations.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Package-owned migrations for @corbits/insights. Bookkeeping uses its -// own ledger table so the package can be extracted without disentangling -// history from the platform drizzle journal. Every table this package -// owns — including its ledger — lives in its own `insights` Postgres -// schema, never `public`; see docs/package-migrations.md. Mechanics -// (schema/ledger bootstrap, transactional apply, the advisory lock -// across concurrent hub replicas) live in @corbits/migration-runner — -// this file owns only the domain SQL. -import { - applyPackageMigrations, - type ApplyPackageMigrationsReport, - type PackageMigration, -} from "@corbits/migration-runner"; - -export type InsightsMigration = PackageMigration; - -const SCHEMA = "insights"; - -export const insightsMigrations: readonly InsightsMigration[] = [ - { - name: "0001_usage_turn", - sql: ` - CREATE TABLE IF NOT EXISTS "insights"."usage_turn" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "session_id" text NOT NULL, - "turn_id" text NOT NULL, - "model" text NOT NULL, - "input_tokens" integer NOT NULL DEFAULT 0, - "cache_read_tokens" integer NOT NULL DEFAULT 0, - "cache_write_tokens" integer NOT NULL DEFAULT 0, - "output_tokens" integer NOT NULL DEFAULT 0, - "thinking_tokens" integer NOT NULL DEFAULT 0, - "recorded_at" timestamptz NOT NULL DEFAULT now() - ); - CREATE UNIQUE INDEX IF NOT EXISTS "usage_turn_turn_id_uidx" - ON "insights"."usage_turn" ("turn_id"); - CREATE INDEX IF NOT EXISTS "usage_turn_tenant_recorded_idx" - ON "insights"."usage_turn" ("tenant_id", "recorded_at"); - `, - }, - { - name: "0002_model_price", - sql: ` - CREATE TABLE IF NOT EXISTS "insights"."model_price" ( - "model" text PRIMARY KEY, - "input_per_m_tok" numeric, - "output_per_m_tok" numeric, - "cache_read_per_m_tok" numeric, - "cache_write_per_m_tok" numeric, - "thinking_per_m_tok" numeric - ); - `, - }, - { - name: "0003_turn_latency", - sql: ` - CREATE TABLE IF NOT EXISTS "insights"."turn_latency" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "session_id" text NOT NULL, - "message_id" text NOT NULL, - "message_run_id" text NOT NULL, - "status" text NOT NULL, - "received_at" timestamptz NOT NULL, - "reactor_start_at" timestamptz, - "inference_start_at" timestamptz, - "first_token_at" timestamptz, - "reply_posted_at" timestamptz NOT NULL, - "recorded_at" timestamptz NOT NULL DEFAULT now() - ); - CREATE UNIQUE INDEX IF NOT EXISTS "turn_latency_message_run_id_uidx" - ON "insights"."turn_latency" ("message_run_id"); - CREATE INDEX IF NOT EXISTS "turn_latency_tenant_recorded_idx" - ON "insights"."turn_latency" ("tenant_id", "recorded_at"); - `, - }, - { - name: "0004_usage_turn_provider_cost", - sql: ` - ALTER TABLE "insights"."usage_turn" - ADD COLUMN IF NOT EXISTS "provider" text, - ADD COLUMN IF NOT EXISTS "reported_cost_usd" numeric; - `, - }, -]; - -const LEDGER_TABLE = "insights_migrations"; - -export type ApplyInsightsMigrationsReport = ApplyPackageMigrationsReport; - -export async function applyInsightsMigrations( - databaseUrl: string, -): Promise { - return applyPackageMigrations({ - databaseUrl, - schema: SCHEMA, - ledgerTable: LEDGER_TABLE, - migrations: insightsMigrations, - packageLabel: "insights", - }); -} diff --git a/packages/insights/src/on-usage-wire.test.ts b/packages/insights/src/on-usage-wire.test.ts deleted file mode 100644 index c4f7c64043..0000000000 --- a/packages/insights/src/on-usage-wire.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createUsageSink } from "./collector"; -import { createMemoryUsageStore } from "./store"; - -/** - * Exact `TurnUsage` payload `@intx/hub-sessions`' event collector emits once - * per finalized turn. Hub `onUsage` (`apps/hub/src/index.ts`) remaps it to - * `UsageEvent` as - * `{ turnId, tenantId, sessionId, provider, model, tokens: usage.usage }` - */ -type HubTurnUsage = { - tenantId: string; - sessionId: string; - runId: string; - turnId: string; - provider: string; - model: string; - usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - thinking: number; - }; -}; - -function hubOnUsageHandleArg(usage: HubTurnUsage) { - return { - turnId: usage.turnId, - tenantId: usage.tenantId, - sessionId: usage.sessionId, - provider: usage.provider, - model: usage.model, - tokens: usage.usage, - }; -} - -describe("hub onUsage wire → createUsageSink", () => { - test("inserts a row from the exact hub remapping of TurnUsage", async () => { - const store = createMemoryUsageStore(); - let n = 0; - const sink = createUsageSink({ - store, - generateId: () => `id-${++n}`, - }); - - const forwarded: HubTurnUsage = { - tenantId: "tenant-acme", - sessionId: "session-1", - runId: "run-1", - turnId: "turn-wire-1", - provider: "anthropic", - model: "claude-sonnet", - usage: { - input: 100, - output: 50, - cacheRead: 10, - cacheWrite: 5, - thinking: 20, - }, - }; - - const status = await sink.handle(hubOnUsageHandleArg(forwarded)); - - expect(status).toBe("inserted"); - const rows = await store.listUsageByTenants(["tenant-acme"]); - expect(rows).toHaveLength(1); - expect(rows[0]?.turnId).toBe("turn-wire-1"); - expect(rows[0]?.tenantId).toBe("tenant-acme"); - expect(rows[0]?.sessionId).toBe("session-1"); - expect(rows[0]?.provider).toBe("anthropic"); - expect(rows[0]?.model).toBe("claude-sonnet"); - expect(rows[0]?.tokens).toEqual({ - input: 100, - output: 50, - cacheRead: 10, - cacheWrite: 5, - thinking: 20, - }); - }); -}); diff --git a/packages/insights/src/pg-store.ts b/packages/insights/src/pg-store.ts deleted file mode 100644 index bdabf565a1..0000000000 --- a/packages/insights/src/pg-store.ts +++ /dev/null @@ -1,207 +0,0 @@ -// Postgres-backed UsageStore against the package-owned usage_turn and -// model_price tables. Created after applyInsightsMigrations on the same URL. - -import postgres from "postgres"; - -import type { TokenClasses, TokenRates } from "./pricing"; -import type { - InsertUsageInput, - ModelPriceRecord, - UsageStore, - UsageTurnRecord, -} from "./store"; - -type Sql = ReturnType; - -type UsageRow = { - id: string; - tenant_id: string; - session_id: string; - turn_id: string; - provider: string | null; - model: string; - input_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - output_tokens: number; - thinking_tokens: number; - reported_cost_usd: string | number | null; - recorded_at: Date; -}; - -function finiteNumberOrNull(value: string | number | null): number | null { - if (value === null) return null; - const number = typeof value === "number" ? value : Number(value); - return Number.isFinite(number) ? number : null; -} - -function tokensFromRow(row: { - input_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - output_tokens: number; - thinking_tokens: number; -}): TokenClasses { - return { - input: row.input_tokens, - cacheRead: row.cache_read_tokens, - cacheWrite: row.cache_write_tokens, - output: row.output_tokens, - thinking: row.thinking_tokens, - }; -} - -function ratesFromRow(row: { - input_per_m_tok: string | number | null; - output_per_m_tok: string | number | null; - cache_read_per_m_tok: string | number | null; - cache_write_per_m_tok: string | number | null; - thinking_per_m_tok: string | number | null; -}): TokenRates { - return { - inputPerMTok: finiteNumberOrNull(row.input_per_m_tok), - outputPerMTok: finiteNumberOrNull(row.output_per_m_tok), - cacheReadPerMTok: finiteNumberOrNull(row.cache_read_per_m_tok), - cacheWritePerMTok: finiteNumberOrNull(row.cache_write_per_m_tok), - thinkingPerMTok: finiteNumberOrNull(row.thinking_per_m_tok), - }; -} - -function usageFromRow(row: UsageRow): UsageTurnRecord { - return { - id: row.id, - tenantId: row.tenant_id, - sessionId: row.session_id, - turnId: row.turn_id, - provider: row.provider, - model: row.model, - tokens: tokensFromRow(row), - reportedCostUsd: finiteNumberOrNull(row.reported_cost_usd), - recordedAt: row.recorded_at, - }; -} - -/** - * Open a UsageStore on an already-migrated Postgres URL. Caller owns - * connection lifetime via the returned `close` handle. - */ -export function createPostgresUsageStore(databaseUrl: string): { - store: UsageStore; - close: () => Promise; -} { - const sql: Sql = postgres(databaseUrl, { - max: 4, - onnotice: () => undefined, - }); - - const store: UsageStore = { - async insertUsage(input: InsertUsageInput) { - const recordedAt = input.recordedAt ?? new Date(); - try { - const rows = await sql` - INSERT INTO insights.usage_turn ( - id, tenant_id, session_id, turn_id, provider, model, - input_tokens, cache_read_tokens, cache_write_tokens, - output_tokens, thinking_tokens, reported_cost_usd, recorded_at - ) VALUES ( - ${input.id}, ${input.tenantId}, ${input.sessionId}, ${input.turnId}, - ${input.provider ?? null}, ${input.model}, - ${input.tokens.input}, ${input.tokens.cacheRead}, - ${input.tokens.cacheWrite}, ${input.tokens.output}, - ${input.tokens.thinking}, ${input.reportedCostUsd ?? null}, ${recordedAt} - ) - ON CONFLICT (turn_id) DO NOTHING - RETURNING * - `; - const row = rows[0]; - if (row === undefined) return null; - return usageFromRow(row); - } catch (error) { - // Unique race on turn_id that ON CONFLICT missed (rare): treat as no-op. - if (error instanceof Error && /unique|duplicate/i.test(error.message)) { - return null; - } - throw error; - } - }, - - async listUsageByTenants(tenantIds, opts) { - if (tenantIds.length === 0) return []; - const from = opts?.from; - const to = opts?.to; - const rows = await sql` - SELECT * FROM insights.usage_turn - WHERE tenant_id = ANY(${[...tenantIds]}) - AND (${from ?? null}::timestamptz IS NULL OR recorded_at >= ${from ?? null}) - AND (${to ?? null}::timestamptz IS NULL OR recorded_at <= ${to ?? null}) - ORDER BY recorded_at ASC - `; - return rows.map(usageFromRow); - }, - - async getPrice(model) { - const rows = await sql< - { - model: string; - input_per_m_tok: string | number | null; - output_per_m_tok: string | number | null; - cache_read_per_m_tok: string | number | null; - cache_write_per_m_tok: string | number | null; - thinking_per_m_tok: string | number | null; - }[] - >` - SELECT * FROM insights.model_price WHERE model = ${model} LIMIT 1 - `; - const row = rows[0]; - if (row === undefined) return null; - return { model: row.model, rates: ratesFromRow(row) }; - }, - - async listPrices() { - const rows = await sql< - { - model: string; - input_per_m_tok: string | number | null; - output_per_m_tok: string | number | null; - cache_read_per_m_tok: string | number | null; - cache_write_per_m_tok: string | number | null; - thinking_per_m_tok: string | number | null; - }[] - >` - SELECT * FROM insights.model_price ORDER BY model ASC - `; - return rows.map((row): ModelPriceRecord => ({ - model: row.model, - rates: ratesFromRow(row), - })); - }, - - async upsertPrice(price) { - const r = price.rates; - await sql` - INSERT INTO insights.model_price ( - model, input_per_m_tok, output_per_m_tok, - cache_read_per_m_tok, cache_write_per_m_tok, thinking_per_m_tok - ) VALUES ( - ${price.model}, - ${r.inputPerMTok}, ${r.outputPerMTok}, - ${r.cacheReadPerMTok}, ${r.cacheWritePerMTok}, ${r.thinkingPerMTok} - ) - ON CONFLICT (model) DO UPDATE SET - input_per_m_tok = EXCLUDED.input_per_m_tok, - output_per_m_tok = EXCLUDED.output_per_m_tok, - cache_read_per_m_tok = EXCLUDED.cache_read_per_m_tok, - cache_write_per_m_tok = EXCLUDED.cache_write_per_m_tok, - thinking_per_m_tok = EXCLUDED.thinking_per_m_tok - `; - return price; - }, - }; - - return { - store, - close: async () => { - await sql.end({ timeout: 5 }); - }, - }; -} diff --git a/packages/insights/src/pricing.test.ts b/packages/insights/src/pricing.test.ts deleted file mode 100644 index 8c2ede3247..0000000000 --- a/packages/insights/src/pricing.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { computeCost, totalTokens } from "./pricing"; - -describe("computeCost", () => { - test("sums known rates into a total", () => { - const result = computeCost( - { - input: 1_000_000, - cacheRead: 0, - cacheWrite: 0, - output: 500_000, - thinking: 0, - }, - { - inputPerMTok: 3, - cacheReadPerMTok: 0.3, - cacheWritePerMTok: 3.75, - outputPerMTok: 15, - thinkingPerMTok: 15, - }, - ); - expect(result.totalUsd).toBe(3 + 7.5); - expect(result.byClass.input).toBe(3); - expect(result.byClass.output).toBe(7.5); - }); - - test("zero-token classes contribute 0 even without a rate", () => { - const result = computeCost( - { input: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0 }, - { - inputPerMTok: 2, - cacheReadPerMTok: null, - cacheWritePerMTok: null, - outputPerMTok: null, - thinkingPerMTok: null, - }, - ); - expect(result.totalUsd).toBe(2); - expect(result.byClass.cacheRead).toBe(0); - expect(result.byClass.output).toBe(0); - }); - - test("missing rate with tokens makes total and class absent (not zero)", () => { - const result = computeCost( - { - input: 1_000_000, - cacheRead: 0, - cacheWrite: 0, - output: 100_000, - thinking: 0, - }, - { - inputPerMTok: 3, - cacheReadPerMTok: null, - cacheWritePerMTok: null, - outputPerMTok: null, - thinkingPerMTok: null, - }, - ); - expect(result.byClass.input).toBe(3); - expect(result.byClass.output).toBeNull(); - expect(result.totalUsd).toBeNull(); - }); - - test("all-null rates with tokens yields fully absent cost", () => { - const result = computeCost( - { input: 10, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0 }, - { - inputPerMTok: null, - cacheReadPerMTok: null, - cacheWritePerMTok: null, - outputPerMTok: null, - thinkingPerMTok: null, - }, - ); - expect(result.totalUsd).toBeNull(); - expect(result.byClass.input).toBeNull(); - }); -}); - -describe("totalTokens", () => { - test("sums every class", () => { - expect( - totalTokens({ - input: 1, - cacheRead: 2, - cacheWrite: 3, - output: 4, - thinking: 5, - }), - ).toBe(15); - }); -}); diff --git a/packages/insights/src/pricing.ts b/packages/insights/src/pricing.ts deleted file mode 100644 index e99d77ad3d..0000000000 --- a/packages/insights/src/pricing.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - computeCost, - totalTokens, - type CostBreakdown, - type TokenClasses, - type TokenRates, -} from "@corbits/provider-pricing"; diff --git a/packages/insights/src/queries.test.ts b/packages/insights/src/queries.test.ts deleted file mode 100644 index 4ee8ed719d..0000000000 --- a/packages/insights/src/queries.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createMemoryUsageStore } from "./store"; -import { - activityByDay, - emptyOverallUsageSummary, - summarizeUsage, - summarizeUsageByTenant, - teamSpaceWorkbenchRows, -} from "./queries"; - -describe("summarizeUsage", () => { - test("empty sink returns zero metrics, not null cost or NaN", async () => { - const store = createMemoryUsageStore(); - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary).toEqual(emptyOverallUsageSummary()); - expect(summary.turns).toBe(0); - expect(summary.costUsd).toBe(0); - expect(summary.tokens.total).toBe(0); - expect(summary.byModel).toEqual([]); - expect(Number.isNaN(summary.costUsd)).toBe(false); - }); - - test("aggregates by model and overall with known rates", async () => { - const store = createMemoryUsageStore(); - - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 1_000_000, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - recordedAt: new Date("2026-08-01T12:00:00Z"), - }); - await store.insertUsage({ - id: "u2", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t2", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 1_000_000, - thinking: 0, - }, - recordedAt: new Date("2026-08-01T13:00:00Z"), - }); - - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary.turns).toBe(2); - expect(summary.tokens.input).toBe(1_000_000); - expect(summary.tokens.output).toBe(1_000_000); - expect(summary.costUsd).toBe(2 + 10); - expect(summary.byModel).toHaveLength(1); - expect(summary.byModel[0]?.model).toBe("claude-sonnet-5"); - }); - - test("uses a provider-reported cost instead of the static rate", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 1_000_000, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - reportedCostUsd: 1.25, - }); - - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary.costUsd).toBe(1.25); - }); - - test("unknown model rate yields null cost, not zero", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - model: "mystery-model", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary.costUsd).toBeNull(); - expect(summary.byModel[0]?.costUsd).toBeNull(); - expect(summary.tokens.input).toBe(100); - }); - - test("tenant isolation — other tenants do not appear", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: { input: 1, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0 }, - }); - await store.insertUsage({ - id: "u2", - tenantId: "tenant-other", - sessionId: "s2", - turnId: "t2", - model: "m", - tokens: { - input: 99, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary.turns).toBe(1); - expect(summary.tokens.input).toBe(1); - }); - - test("multi-tenant scope aggregates equal the sum of each tenant alone", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "workbench-a", - sessionId: "s1", - turnId: "t1", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "u2", - tenantId: "workbench-b", - sessionId: "s2", - turnId: "t2", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 200, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "u3", - tenantId: "workbench-unrelated", - sessionId: "s3", - turnId: "t3", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 9999, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - - const aggregate = await summarizeUsage(store, [ - "workbench-a", - "workbench-b", - ]); - const a = await summarizeUsage(store, ["workbench-a"]); - const b = await summarizeUsage(store, ["workbench-b"]); - - expect(aggregate.turns).toBe(a.turns + b.turns); - expect(aggregate.tokens.input).toBe(a.tokens.input + b.tokens.input); - expect(aggregate.tokens.output).toBe(a.tokens.output + b.tokens.output); - expect(aggregate.costUsd).toBe((a.costUsd ?? 0) + (b.costUsd ?? 0)); - // The unrelated third tenant never leaks into a two-tenant scope. - expect(aggregate.tokens.input).toBe(100); - }); -}); - -describe("activityByDay", () => { - test("empty sink returns empty series (no fabricated peaks)", async () => { - const store = createMemoryUsageStore(); - const days = await activityByDay(store, ["tenant-acme"]); - expect(days).toEqual([]); - }); - - test("buckets by UTC day", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: { - input: 10, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - recordedAt: new Date("2026-08-01T23:00:00Z"), - }); - await store.insertUsage({ - id: "u2", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t2", - model: "m", - tokens: { input: 5, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0 }, - recordedAt: new Date("2026-08-02T01:00:00Z"), - }); - - const days = await activityByDay(store, ["tenant-acme"]); - expect(days).toEqual([ - { - day: "2026-08-01", - turns: 1, - tokens: 10, - byModel: [{ model: "m", tokens: 10, costUsd: null }], - }, - { - day: "2026-08-02", - turns: 1, - tokens: 5, - byModel: [{ model: "m", tokens: 5, costUsd: null }], - }, - ]); - }); - - test("splits each day's tokens/cost by model for a stacked chart", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 1_000_000, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - recordedAt: new Date("2026-08-01T10:00:00Z"), - }); - await store.insertUsage({ - id: "u2", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t2", - model: "gpt-unpriced", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - recordedAt: new Date("2026-08-01T11:00:00Z"), - }); - - const [day] = await activityByDay(store, ["tenant-acme"]); - expect(day?.byModel).toEqual([ - { model: "claude-sonnet-5", tokens: 1_000_000, costUsd: 2 }, - { model: "gpt-unpriced", tokens: 100, costUsd: null }, - ]); - }); - - test("multi-tenant scope buckets across all tenants in scope", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "workbench-a", - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: { - input: 10, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - recordedAt: new Date("2026-08-01T10:00:00Z"), - }); - await store.insertUsage({ - id: "u2", - tenantId: "workbench-b", - sessionId: "s2", - turnId: "t2", - model: "m", - tokens: { input: 5, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0 }, - recordedAt: new Date("2026-08-01T11:00:00Z"), - }); - - const days = await activityByDay(store, ["workbench-a", "workbench-b"]); - expect(days).toEqual([ - { - day: "2026-08-01", - turns: 2, - tokens: 15, - byModel: [{ model: "m", tokens: 15, costUsd: null }], - }, - ]); - }); -}); - -describe("summarizeUsageByTenant", () => { - test("every requested tenant id gets an entry, zeroed when it recorded nothing", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "workbench-a", - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - - const rows = await summarizeUsageByTenant(store, [ - "workbench-a", - "workbench-b", - ]); - expect(rows).toEqual([ - { - tenantId: "workbench-a", - turns: 1, - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 100, - }, - costUsd: null, - }, - { - tenantId: "workbench-b", - turns: 0, - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }, - costUsd: 0, - }, - ]); - }); - - test("per-tenant totals sum to the same aggregate summarizeUsage reports for that scope", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "workbench-a", - sessionId: "s1", - turnId: "t1", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 1_000_000, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "u2", - tenantId: "workbench-b", - sessionId: "s2", - turnId: "t2", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 1_000_000, - thinking: 0, - }, - }); - - const scope = ["workbench-a", "workbench-b"]; - const perTenant = await summarizeUsageByTenant(store, scope); - const aggregate = await summarizeUsage(store, scope); - - const summedTurns = perTenant.reduce((sum, w) => sum + w.turns, 0); - const summedCost = perTenant.reduce((sum, w) => sum + (w.costUsd ?? 0), 0); - expect(summedTurns).toBe(aggregate.turns); - expect(aggregate.costUsd).not.toBeNull(); - expect(summedCost).toBe(aggregate.costUsd ?? 0); - }); -}); - -describe("CL-6659 unreported tokens and team-space breakdowns", () => { - const zeroTokens = { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }; - - test("a recorded turn with no token counts is null cost, not $0.00", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - provider: "ollama", - model: "qwen3:latest", - tokens: zeroTokens, - }); - - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary.turns).toBe(1); - expect(summary.tokens.total).toBe(0); - expect(summary.costUsd).toBeNull(); - expect(summary.byModel[0]?.costUsd).toBeNull(); - }); - - test("ollama turns with real tokens and a FREE catalog row cost $0, not null", async () => { - const store = createMemoryUsageStore(); - await store.insertUsage({ - id: "u1", - tenantId: "tenant-acme", - sessionId: "s1", - turnId: "t1", - provider: "ollama", - model: "qwen3.8:27b", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 50, - thinking: 0, - }, - }); - - const summary = await summarizeUsage(store, ["tenant-acme"]); - expect(summary.turns).toBe(1); - expect(summary.tokens.total).toBe(150); - expect(summary.costUsd).toBe(0); - }); - - test("team-space parent with recorded turns stays in the workbench breakdown", () => { - const rows = [ - { - tenantId: "team-root", - turns: 3, - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }, - costUsd: null, - }, - { - tenantId: "workbench-a", - turns: 0, - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }, - costUsd: 0, - }, - ]; - const kept = teamSpaceWorkbenchRows(rows, { - tenantId: "team-root", - isTeamSpace: true, - }); - expect(kept.map((r) => r.tenantId)).toEqual(["team-root", "workbench-a"]); - }); - - test("empty team-space parent is still dropped as a duplicate of All workbenches", () => { - const rows = [ - { - tenantId: "team-root", - turns: 0, - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - total: 0, - }, - costUsd: 0, - }, - { - tenantId: "workbench-a", - turns: 2, - tokens: { - input: 10, - cacheRead: 0, - cacheWrite: 0, - output: 5, - thinking: 0, - total: 15, - }, - costUsd: null, - }, - ]; - const kept = teamSpaceWorkbenchRows(rows, { - tenantId: "team-root", - isTeamSpace: true, - }); - expect(kept.map((r) => r.tenantId)).toEqual(["workbench-a"]); - }); -}); diff --git a/packages/insights/src/queries.ts b/packages/insights/src/queries.ts deleted file mode 100644 index fc0a7fcbe8..0000000000 --- a/packages/insights/src/queries.ts +++ /dev/null @@ -1,470 +0,0 @@ -import type { AuditAuthz } from "@intx/types/audit"; - -import { - costUsd, - totalTokens, - type TokenClasses, -} from "@corbits/provider-pricing"; - -import type { UsageStore, UsageTurnRecord } from "./store"; -import type { TurnLatencyStore } from "./latency-store"; - -export type TokenTotals = { - readonly input: number; - readonly cacheRead: number; - readonly cacheWrite: number; - readonly output: number; - readonly thinking: number; - readonly total: number; -}; - -export type ModelUsageSummary = { - readonly model: string; - readonly turns: number; - readonly tokens: TokenTotals; - /** - * USD cost for this model, or null when any contributing turn has no - * reported cost and no (provider, model) rate. - */ - readonly costUsd: number | null; -}; - -export type OverallUsageSummary = { - readonly turns: number; - readonly tokens: TokenTotals; - readonly costUsd: number | null; - readonly byModel: readonly ModelUsageSummary[]; -}; - -/** One model's tokens/cost within a single day bucket. */ -export type ModelDayUsage = { - readonly model: string; - readonly tokens: number; - /** USD cost for this model on this day, or null when its rate is unknown. */ - readonly costUsd: number | null; -}; - -export type DayActivity = { - /** ISO date (YYYY-MM-DD) in UTC. */ - readonly day: string; - readonly turns: number; - readonly tokens: number; - /** Same day's tokens/cost split by model — the global landing's - * tokens/cost-over-time chart stacks on this rather than re-querying. */ - readonly byModel: readonly ModelDayUsage[]; -}; - -/** One workbench's usage totals — the global landing's per-workbench - * activity chart. */ -export type WorkbenchUsage = { - readonly tenantId: string; - readonly turns: number; - readonly tokens: TokenTotals; - readonly costUsd: number | null; -}; - -function emptyTokens(): TokenClasses { - return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0 }; -} - -function addTokens(a: TokenClasses, b: TokenClasses): TokenClasses { - return { - input: a.input + b.input, - cacheRead: a.cacheRead + b.cacheRead, - cacheWrite: a.cacheWrite + b.cacheWrite, - output: a.output + b.output, - thinking: a.thinking + b.thinking, - }; -} - -function toTotals(t: TokenClasses): TokenTotals { - return { ...t, total: totalTokens(t) }; -} - -/** Empty-sink usage summary: zero turns/tokens/cost, no model rows. */ -export function emptyOverallUsageSummary(): OverallUsageSummary { - return { - turns: 0, - tokens: toTotals(emptyTokens()), - costUsd: 0, - byModel: [], - }; -} - -function turnCostUsd(row: UsageTurnRecord): number | null { - if (row.reportedCostUsd !== null) return row.reportedCostUsd; - // A recorded turn with no token counts is not $0 spend — we did not - // observe usage, so cost is unknown (CL-6659). Catalog FREE rates still - // apply once tokens are present. - if (totalTokens(row.tokens) === 0) return null; - return costUsd({ - provider: row.provider ?? "", - model: row.model, - tokens: row.tokens, - }); -} - -function addCosts(a: number | null, b: number | null): number | null { - if (a === null || b === null) return null; - return a + b; -} - -/** Fold a row set already scoped to the caller's tenant(s) into an overall - * summary — the shared core `summarizeUsage` and `summarizeUsageByTenant` - * both reduce to, so per-tenant and cross-tenant totals can never drift - * apart in how a rate is applied or a null cost is decided. */ -function summarizeRows(rows: readonly UsageTurnRecord[]): OverallUsageSummary { - if (rows.length === 0) return emptyOverallUsageSummary(); - - const byModel = new Map< - string, - { turns: number; tokens: TokenClasses; costUsd: number | null } - >(); - for (const row of rows) { - const current = byModel.get(row.model) ?? { - turns: 0, - tokens: emptyTokens(), - costUsd: 0, - }; - byModel.set(row.model, { - turns: current.turns + 1, - tokens: addTokens(current.tokens, row.tokens), - costUsd: addCosts(current.costUsd, turnCostUsd(row)), - }); - } - - const modelSummaries: ModelUsageSummary[] = []; - let overallTokens = emptyTokens(); - let overallTurns = 0; - let overallCost: number | null = 0; - - for (const [model, agg] of [...byModel.entries()].sort(([a], [b]) => - a.localeCompare(b), - )) { - modelSummaries.push({ - model, - turns: agg.turns, - tokens: toTotals(agg.tokens), - costUsd: agg.costUsd, - }); - - overallTokens = addTokens(overallTokens, agg.tokens); - overallTurns += agg.turns; - overallCost = addCosts(overallCost, agg.costUsd); - } - - return { - turns: overallTurns, - tokens: toTotals(overallTokens), - costUsd: overallCost, - byModel: modelSummaries, - }; -} - -/** - * Aggregate usage by model and overall for a tenant scope. `tenantIds` - * is one tenant for a single-workbench view, or a workspace parent plus - * its child workbenches for the cross-workbench rollup — the sum happens - * here, at the DB-query layer, not by the caller fetching per tenant and - * adding client-side. Empty sink → zeros (see emptyOverallUsageSummary). - * Cost is null when any contributing class lacks a rate — never a - * fabricated cost for unknown rates. - */ -export async function summarizeUsage( - store: UsageStore, - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, -): Promise { - const rows = await store.listUsageByTenants(tenantIds, opts); - if (rows.length === 0) return emptyOverallUsageSummary(); - return summarizeRows(rows); -} - -/** - * Same rollup as `summarizeUsage`, but split back out per tenant — the - * global Insights landing's "activity by workbench" chart, so it can rank - * and link to individual workbenches instead of only seeing their sum. - * Every id in `tenantIds` gets an entry, zeroed when that tenant recorded - * no usage in range, so a quiet workbench still shows up as a zero bar - * rather than silently disappearing from the ranking. - */ -export async function summarizeUsageByTenant( - store: UsageStore, - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, -): Promise { - const rows = await store.listUsageByTenants(tenantIds, opts); - - const byTenant = new Map(); - for (const row of rows) { - const bucket = byTenant.get(row.tenantId); - if (bucket === undefined) byTenant.set(row.tenantId, [row]); - else bucket.push(row); - } - - return tenantIds.map((tenantId) => { - const summary = summarizeRows(byTenant.get(tenantId) ?? []); - return { - tenantId, - turns: summary.turns, - tokens: summary.tokens, - costUsd: summary.costUsd, - }; - }); -} - -/** - * Team-space `/workbenches` rows: drop an empty parent (it is a duplicate - * of the "All workbenches" landing, CL-6368). Keep the parent when it - * recorded turns of its own — otherwise those turns vanish from the - * breakdown (CL-6659). - */ -export function teamSpaceWorkbenchRows< - T extends { readonly tenantId: string; readonly turns: number }, ->( - rows: readonly T[], - opts: { readonly tenantId: string; readonly isTeamSpace: boolean }, -): readonly T[] { - if (!opts.isTeamSpace) return rows; - return rows.filter((row) => row.tenantId !== opts.tenantId || row.turns > 0); -} - -/** - * Activity histogram by UTC day across a tenant scope (see summarizeUsage - * for what `tenantIds` means). Token totals are known when the adapter - * reported them; a recorded turn with every class at zero is an honest - * absence, not $0.00. Pre-sink history simply does not appear. - */ -export async function activityByDay( - store: UsageStore, - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, -): Promise { - const rows = await store.listUsageByTenants(tenantIds, opts); - const days = new Map< - string, - { - turns: number; - tokens: number; - byModel: Map; - } - >(); - - for (const row of rows) { - const day = row.recordedAt.toISOString().slice(0, 10); - const current = days.get(day) ?? { - turns: 0, - tokens: 0, - byModel: new Map(), - }; - current.turns += 1; - current.tokens += totalTokens(row.tokens); - const modelAgg = current.byModel.get(row.model) ?? { - tokens: emptyTokens(), - costUsd: 0, - }; - current.byModel.set(row.model, { - tokens: addTokens(modelAgg.tokens, row.tokens), - costUsd: addCosts(modelAgg.costUsd, turnCostUsd(row)), - }); - days.set(day, current); - } - - return [...days.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, agg]) => ({ - day, - turns: agg.turns, - tokens: agg.tokens, - byModel: [...agg.byModel.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([model, modelAgg]) => ({ - model, - tokens: totalTokens(modelAgg.tokens), - costUsd: modelAgg.costUsd, - })), - })); -} - -/** - * Run-trace detail is owned by workflow_run / inference_turn / turn_part - * in the platform schema (see @corbits/insights' createDrizzleRunTraceReader - * for the concrete reader). This package's route layer does not re-query - * those tables itself — the hub mount injects a RunTraceReader. A tenant - * that mounts none receives an explicit absent result rather than a - * fabricated empty trace. - */ -export type RunTraceSpan = { - readonly id: string; - readonly label: string; - readonly kind: string; - readonly start: number; - readonly end: number; - readonly durationMs: number | null; - readonly tokens: TokenClasses | null; - readonly phase: "ok" | "awaiting" | "failed"; - readonly error: string | null; - /** - * Authorization verdict for a `kind: "tool"` span. Verdicts are recorded - * only in the sidecar-side git-backed audit trail (`AuditRecord`, written - * via `IsogitStore.commitAudit`), which the hub's Postgres-only - * composition root has no read path into today — so this is always - * `null` for now, an honest absence rather than a fabricated verdict. - * The field exists so CL-5927 (Settings · Audit) can consume tool-call - * rows from this same reader once that read path is wired. - * Only ever populated for `kind: "tool"` spans (other kinds never carry - * a verdict); `undefined` and `null` are not interchangeable here — - * optional (`authz?:`) only because most call sites never set it, while - * the reader always sets `null` explicitly for tool spans as an honest - * "not reachable yet" rather than an unset "no opinion on whether this - * exists". - */ - readonly authz?: AuditAuthz | null; - /** - * How this span's start/end were derived. "measured" means real - * wall-clock timestamps (inference_turn.startedAt/endedAt); "ordinal" - * means the span was positioned by turn_part.ordinal within its - * enclosing turn's window, not a real timestamp (see `positionInTurn` - * in trace-reader.ts) — an honest sequence marker, not a duration - * measurement. - */ - readonly timingSource: "measured" | "ordinal"; -}; - -export type RunTrace = { - readonly runId: string; - readonly spans: readonly RunTraceSpan[]; -}; - -export type RunTraceReader = { - getTrace(tenantId: string, runId: string): Promise; -}; - -export type ToolCallSummary = { - readonly tool: string; - readonly calls: number; - readonly errors: number; - readonly errorRate: number | null; -}; - -/** - * Calls-by-tool requires turn_part access. When no reader is mounted, - * return an empty list (no fabricated tools) and document the gap. - * `tenantIds` carries the same single-tenant-or-scope contract as - * summarizeUsage/activityByDay — a real reader owns merging across the - * scope itself, at its own query layer. - */ -export type ToolCallReader = { - summarize( - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, - ): Promise; -}; - -export function emptyToolCallReader(): ToolCallReader { - return { - async summarize() { - return []; - }, - }; -} - -/** - * Nearest-rank percentile over already-sorted-ascending values. Null on - * an empty input — no fabricated p50/p95 for a stage with zero samples. - */ -export function percentile( - sorted: readonly number[], - p: number, -): number | null { - if (sorted.length === 0) return null; - const rank = Math.ceil(p * sorted.length) - 1; - const clamped = Math.min(Math.max(rank, 0), sorted.length - 1); - return sorted[clamped] ?? null; -} - -/** p50/p95 (milliseconds) over one stage's durations, or null when that - * stage recorded no samples in range — e.g. `reactorStart` on a scope - * with no cold starts this window. */ -export type LatencyStageStat = { - readonly p50Ms: number | null; - readonly p95Ms: number | null; - readonly samples: number; -}; - -export type LatencySummary = { - /** message-received → reactor.start (cold starts only; see TurnLatencyRecord). */ - readonly toReactorStart: LatencyStageStat; - /** reactor.start (or message-received, on a warm session) → inference.start. */ - readonly toInferenceStart: LatencyStageStat; - /** inference.start → first token. */ - readonly toFirstToken: LatencyStageStat; - /** first token → reply posted. */ - readonly toReplyPosted: LatencyStageStat; - /** message-received → reply posted, end to end. */ - readonly total: LatencyStageStat; -}; - -function stageStat(durationsMs: number[]): LatencyStageStat { - const sorted = [...durationsMs].sort((a, b) => a - b); - return { - p50Ms: percentile(sorted, 0.5), - p95Ms: percentile(sorted, 0.95), - samples: sorted.length, - }; -} - -/** - * Aggregates recorded message-run latency into p50/p95 per stage across a - * tenant scope (same `tenantIds` contract as summarizeUsage). A row - * missing a stage's timestamp (a warm session's null `reactorStartAt`) - * simply contributes no sample to that stage — it is not zeroed and it - * is not dropped from the other stages it did record. - */ -export async function summarizeLatency( - store: TurnLatencyStore, - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, -): Promise { - const rows = await store.listLatencyByTenants(tenantIds, opts); - - const toReactorStart: number[] = []; - const toInferenceStart: number[] = []; - const toFirstToken: number[] = []; - const toReplyPosted: number[] = []; - const total: number[] = []; - - for (const row of rows) { - const receivedAt = row.receivedAt.getTime(); - const replyPostedAt = row.replyPostedAt.getTime(); - total.push(replyPostedAt - receivedAt); - - if (row.reactorStartAt !== null) { - toReactorStart.push(row.reactorStartAt.getTime() - receivedAt); - } - const inferenceStartFrom = row.reactorStartAt ?? row.receivedAt; - if (row.inferenceStartAt !== null) { - toInferenceStart.push( - row.inferenceStartAt.getTime() - inferenceStartFrom.getTime(), - ); - } - if (row.inferenceStartAt !== null && row.firstTokenAt !== null) { - toFirstToken.push( - row.firstTokenAt.getTime() - row.inferenceStartAt.getTime(), - ); - } - if (row.firstTokenAt !== null) { - toReplyPosted.push(replyPostedAt - row.firstTokenAt.getTime()); - } - } - - return { - toReactorStart: stageStat(toReactorStart), - toInferenceStart: stageStat(toInferenceStart), - toFirstToken: stageStat(toFirstToken), - toReplyPosted: stageStat(toReplyPosted), - total: stageStat(total), - }; -} - -export type { UsageTurnRecord }; diff --git a/packages/insights/src/routes.trace.test.ts b/packages/insights/src/routes.trace.test.ts deleted file mode 100644 index 703c7e0b36..0000000000 --- a/packages/insights/src/routes.trace.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// The /runs/:runId/trace lookup fires on every run-detail open, so both -// "no reader mounted" and "no recorded trace" are normal states that must -// answer the absent envelope with 200 — never a 404 the client cannot -// tell apart from a broken path. routes-run-trace.test.ts (DB-gated) -// proves the present-trace path against real tables. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; - -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { createInsightsRoutes } from "./routes"; -import { createMemoryUsageStore } from "./store"; - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -function mount(runTraceReader?: { - getTrace: (tenantId: string, runId: string) => Promise; -}): Hono { - const routes = createInsightsRoutes({ - store: createMemoryUsageStore(), - requireGrant: allowAll, - ...(runTraceReader !== undefined ? { runTraceReader } : {}), - }); - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", { id: "tnt_1" } as never); - c.set("principal", { id: "prn_1" } as never); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - return app; -} - -describe("GET /runs/:runId/trace absent envelopes", () => { - test("answers 200 with absent when no reader is mounted", async () => { - const response = await mount().request("/runs/run_1/trace"); - expect(response.status).toBe(200); - const body = (await response.json()) as { - runId: string; - spans: unknown; - absent: string; - }; - expect(body.runId).toBe("run_1"); - expect(body.spans).toBeNull(); - expect(body.absent).toBe("run_trace_reader_not_mounted"); - }); - - test("answers 200 with absent when the run has no recorded trace", async () => { - const app = mount({ getTrace: async () => null }); - const response = await app.request("/runs/run_1/trace"); - expect(response.status).toBe(200); - const body = (await response.json()) as { - runId: string; - spans: unknown; - absent: string; - }; - expect(body.runId).toBe("run_1"); - expect(body.spans).toBeNull(); - expect(body.absent).toBe("trace_not_recorded"); - }); -}); diff --git a/packages/insights/src/routes.ts b/packages/insights/src/routes.ts deleted file mode 100644 index 08cd71efe3..0000000000 --- a/packages/insights/src/routes.ts +++ /dev/null @@ -1,353 +0,0 @@ -// Tenant-scoped Insights read API. Mounted under the platform's native -// tenant middleware; every number that cannot be known is returned as -// null, never a fabricated zero. usage/activity/tools roll up the -// requested tenant's whole descendant subtree when `deps.db` is wired -// (see resolveScope) — the same route serves a single workbench's own -// numbers (leaf, no descendants) and a workspace's cross-workbench -// aggregate (parent, its child workbenches). `/scope` is the read-only -// counterpart a caller uses to discover that shape: its own name, its -// parent (if any), and the sibling workbenches to switch between — -// filtered to tenants the caller holds an active principal in (see -// callerTenantIds), never a sibling or parent name the caller has no -// membership in. -import { Hono } from "hono"; -import { type } from "arktype"; -import { eq, and, inArray } from "drizzle-orm"; - -import { getDescendantTenants, schema, type DB } from "@intx/db"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { - activityByDay, - emptyToolCallReader, - summarizeLatency, - summarizeUsage, - summarizeUsageByTenant, - teamSpaceWorkbenchRows, - type RunTraceReader, - type ToolCallReader, -} from "./queries"; -import type { UsageStore } from "./store"; -import type { TurnLatencyStore } from "./latency-store"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -const RangeQuery = type({ - "from?": "string", - "to?": "string", -}); - -function parseRange(raw: { from?: string; to?: string }): - | { - from?: Date; - to?: Date; - } - | type.errors { - const opts: { from?: Date; to?: Date } = {}; - if (raw.from !== undefined) { - const d = new Date(raw.from); - if (Number.isNaN(d.getTime())) { - return type.errors as unknown as type.errors; - } - opts.from = d; - } - if (raw.to !== undefined) { - const d = new Date(raw.to); - if (Number.isNaN(d.getTime())) { - return type.errors as unknown as type.errors; - } - opts.to = d; - } - return opts; -} - -export type CreateInsightsRoutesDeps = { - store: UsageStore; - requireGrant: RequireGrant; - runTraceReader?: RunTraceReader; - toolCallReader?: ToolCallReader; - /** CL-6257 per-message-run stage latency (message-received → reactor.start - * → inference.start → first-token → reply-posted). Omitted mounts, no - * `/latency` numbers — `/latency` still returns 200 with every stage - * null rather than 404, since latency is an optional overlay, not a - * required Insights capability. */ - latencyStore?: TurnLatencyStore; - /** - * Tenant-hierarchy handle for scope resolution. usage/activity/tools - * aggregate over the requested tenant plus every descendant it has - * (see getDescendantTenants) — no separate "aggregate" flag or route: - * calling with a workbench's own id stays a single-tenant view (it has - * no descendants), calling with its workspace parent rolls up every - * child workbench, at this query layer rather than one fetch per - * tenant. Omitted, every query stays scoped to exactly the requested - * tenant (no hierarchy lookup, same behavior as before this scope - * existed). - */ - db?: DB["db"]; -}; - -async function resolveScope( - db: DB["db"] | undefined, - tenantId: string, -): Promise { - if (db === undefined) return [tenantId]; - return getDescendantTenants(db, tenantId); -} - -/** - * Shared `?from=&to=` parsing for every range-scoped route below (`/usage`, - * `/activity`, `/tools`, `/workbenches`) — one bad-request shape instead of - * four near-identical copies drifting apart. - */ -function parseRangeQuery( - query: Record, -): { from?: Date; to?: Date } | Response { - const raw = RangeQuery(query); - if (raw instanceof type.errors) { - return Response.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid query: ${raw.summary}`, - }), - { status: 400 }, - ); - } - const range = parseRange(raw); - if (range instanceof type.errors) { - return Response.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "invalid from/to timestamp", - }), - { status: 400 }, - ); - } - return range; -} - -/** - * Display names for a tenant scope. Never falls back to querying the DB - * with an empty `id` list, and never invents a name — a scope id with no - * matching tenant row (deleted between the descendant walk and this - * lookup) just falls back to its own id, same as `/scope`'s own - * best-effort labeling does for a parent row that vanished mid-request. - */ -async function tenantNames( - db: DB["db"] | undefined, - tenantId: string, - tenantName: string, - scope: readonly string[], -): Promise> { - if (db === undefined || scope.length === 0) { - return new Map([[tenantId, tenantName]]); - } - const rows = await db.query.tenant.findMany({ - where: inArray(schema.tenant.id, scope as string[]), - columns: { id: true, name: true }, - }); - return new Map(rows.map((r) => [r.id, r.name])); -} - -/** - * The set of tenant ids the calling user holds an active principal in — - * the same `principal.kind === "user" && principal.refId === user.id` - * lookup `/api/me/principals` (vendor's `routes/me.ts`) uses to derive a - * user's cross-tenant memberships. `/scope` intersects this against a - * tenant's siblings/parent so it can never name a tenant the caller has - * no membership in, regardless of which tenant they asked about. - */ -async function callerTenantIds( - db: DB["db"], - userId: string, -): Promise> { - const rows = await db.query.principal.findMany({ - where: and( - eq(schema.principal.kind, "user"), - eq(schema.principal.refId, userId), - eq(schema.principal.status, "active"), - ), - columns: { tenantId: true }, - }); - return new Set(rows.map((r) => r.tenantId)); -} - -export function createInsightsRoutes( - deps: CreateInsightsRoutesDeps, -): Hono { - const app = new Hono(); - const tools = deps.toolCallReader ?? emptyToolCallReader(); - - app.get("/usage", deps.requireGrant("insights:*", "read"), async (c) => { - const range = parseRangeQuery(c.req.query()); - if (range instanceof Response) return range; - const tenant = c.get("tenant"); - const scope = await resolveScope(deps.db, tenant.id); - const summary = await summarizeUsage(deps.store, scope, range); - return c.json(summary); - }); - - app.get("/activity", deps.requireGrant("insights:*", "read"), async (c) => { - const range = parseRangeQuery(c.req.query()); - if (range instanceof Response) return range; - const tenant = c.get("tenant"); - const scope = await resolveScope(deps.db, tenant.id); - const days = await activityByDay(deps.store, scope, range); - return c.json({ days }); - }); - - app.get("/tools", deps.requireGrant("insights:*", "read"), async (c) => { - const range = parseRangeQuery(c.req.query()); - if (range instanceof Response) return range; - const tenant = c.get("tenant"); - const scope = await resolveScope(deps.db, tenant.id); - const toolsSummary = await tools.summarize(scope, range); - return c.json({ tools: toolsSummary }); - }); - - /** - * The global Insights landing's "activity by workbench" chart: the same - * usage rollup `/usage` already computes for the whole scope, split back - * out per tenant and named — so the landing can rank workbenches and - * link each bar to `/insights/workbench/:tenantId` instead of only - * seeing the scope's sum. Calling it for a leaf workbench (no - * descendants) returns that one workbench's own row. - * - * A `parentId === null` requested tenant is the account root, the - * container real workbenches live under (CL-6089). An empty parent row - * is a duplicate of the "All workbenches" landing and is dropped - * (CL-6368). A parent that recorded turns of its own stays in the - * breakdown so those turns are not silently excluded (CL-6659). - */ - app.get( - "/workbenches", - deps.requireGrant("insights:*", "read"), - async (c) => { - const range = parseRangeQuery(c.req.query()); - if (range instanceof Response) return range; - const tenant = c.get("tenant"); - const scope = await resolveScope(deps.db, tenant.id); - const [rows, names] = await Promise.all([ - summarizeUsageByTenant(deps.store, scope, range), - tenantNames(deps.db, tenant.id, tenant.name, scope), - ]); - const items = teamSpaceWorkbenchRows(rows, { - tenantId: tenant.id, - isTeamSpace: tenant.parentId === null, - }) - .map((row) => ({ - tenantId: row.tenantId, - name: names.get(row.tenantId) ?? row.tenantId, - turns: row.turns, - tokens: row.tokens, - costUsd: row.costUsd, - })) - .sort((a, b) => b.turns - a.turns); - return c.json({ items }); - }, - ); - - app.get("/latency", deps.requireGrant("insights:*", "read"), async (c) => { - const range = parseRangeQuery(c.req.query()); - if (range instanceof Response) return range; - if (deps.latencyStore === undefined) { - return c.json({ - toReactorStart: { p50Ms: null, p95Ms: null, samples: 0 }, - toInferenceStart: { p50Ms: null, p95Ms: null, samples: 0 }, - toFirstToken: { p50Ms: null, p95Ms: null, samples: 0 }, - toReplyPosted: { p50Ms: null, p95Ms: null, samples: 0 }, - total: { p50Ms: null, p95Ms: null, samples: 0 }, - }); - } - const tenant = c.get("tenant"); - const scope = await resolveScope(deps.db, tenant.id); - const summary = await summarizeLatency(deps.latencyStore, scope, range); - return c.json(summary); - }); - - app.get("/scope", deps.requireGrant("insights:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const self = { tenantId: tenant.id, name: tenant.name }; - const selfOnly = () => - c.json({ - tenantId: tenant.id, - name: tenant.name, - parent: null, - workbenches: [self], - }); - const user = c.get("user"); - if (deps.db === undefined || tenant.parentId === null || user === null) { - return selfOnly(); - } - const memberTenantIds = await callerTenantIds(deps.db, user.id); - const siblings = await deps.db.query.tenant.findMany({ - where: eq(schema.tenant.parentId, tenant.parentId), - columns: { id: true, name: true }, - }); - // Only siblings (and self) the caller actually holds a principal in — - // never a name or tenantId belonging to a tenant they aren't a member - // of, no matter what the requested tenant's own membership allows. - const workbenches = siblings.filter((s) => memberTenantIds.has(s.id)); - if (!memberTenantIds.has(tenant.parentId)) { - return c.json({ - tenantId: tenant.id, - name: tenant.name, - parent: null, - workbenches: workbenches.map((s) => ({ tenantId: s.id, name: s.name })), - }); - } - const parentRow = await deps.db.query.tenant.findFirst({ - where: eq(schema.tenant.id, tenant.parentId), - columns: { id: true, name: true }, - }); - if (parentRow === undefined) { - return c.json({ - tenantId: tenant.id, - name: tenant.name, - parent: null, - workbenches: workbenches.map((s) => ({ tenantId: s.id, name: s.name })), - }); - } - return c.json({ - tenantId: tenant.id, - name: tenant.name, - parent: { tenantId: parentRow.id, name: parentRow.name }, - workbenches: workbenches.map((s) => ({ tenantId: s.id, name: s.name })), - }); - }); - - app.get( - "/runs/:runId/trace", - deps.requireGrant("insights:*", "read"), - async (c) => { - const tenant = c.get("tenant"); - const runId = c.req.param("runId"); - if (deps.runTraceReader === undefined) { - return c.json( - { - runId, - spans: null, - absent: "run_trace_reader_not_mounted", - }, - 200, - ); - } - // No recorded trace is a normal state for a run (recorded before - // tracing, or trace rows pruned) — answer the same absent envelope - // as an unmounted reader instead of 404ing a lookup the run-detail - // page fires on every open. - const trace = await deps.runTraceReader.getTrace(tenant.id, runId); - if (trace === null) { - return c.json( - { - runId, - spans: null, - absent: "trace_not_recorded", - }, - 200, - ); - } - return c.json(trace); - }, - ); - - return app; -} diff --git a/packages/insights/src/schema.ts b/packages/insights/src/schema.ts deleted file mode 100644 index 1413e4d5a3..0000000000 --- a/packages/insights/src/schema.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Workbench-owned usage and pricing tables. Inference emits token -// usage on the live event stream but the platform event-collector -// deliberately drops it — this package is the product sink that -// persists those events so Insights can query real numbers. -// -// These tables live in their own `insights` Postgres schema, fully -// siloed from the platform's `public` schema — see -// docs/package-migrations.md. `tenantId` is a plain text identifier, -// not a foreign key, so referencing platform tenant/principal ids -// works identically from a named schema. -import { - integer, - numeric, - pgSchema, - text, - timestamp, - uniqueIndex, -} from "drizzle-orm/pg-core"; - -export const insightsSchema = pgSchema("insights"); - -/** - * One row per model-turn of inference usage. `turnId` is unique so a - * collector restart can re-deliver the same event without double-counting. - */ -export const usageTurn = insightsSchema.table( - "usage_turn", - { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - sessionId: text("session_id").notNull(), - turnId: text("turn_id").notNull(), - provider: text("provider"), - model: text("model").notNull(), - inputTokens: integer("input_tokens").notNull().default(0), - cacheReadTokens: integer("cache_read_tokens").notNull().default(0), - cacheWriteTokens: integer("cache_write_tokens").notNull().default(0), - outputTokens: integer("output_tokens").notNull().default(0), - thinkingTokens: integer("thinking_tokens").notNull().default(0), - reportedCostUsd: numeric("reported_cost_usd"), - recordedAt: timestamp("recorded_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [uniqueIndex("usage_turn_turn_id_uidx").on(table.turnId)], -); - -export type UsageTurnRow = typeof usageTurn.$inferSelect; - -/** - * Operator-editable static price table. A null rate means "no rate - * known" — cost queries must return absent for that class, never a - * fabricated zero. - */ -export const modelPrice = insightsSchema.table("model_price", { - model: text("model").primaryKey(), - /** USD per 1M input tokens. Null = no rate. */ - inputPerMTok: numeric("input_per_m_tok"), - outputPerMTok: numeric("output_per_m_tok"), - cacheReadPerMTok: numeric("cache_read_per_m_tok"), - cacheWritePerMTok: numeric("cache_write_per_m_tok"), - thinkingPerMTok: numeric("thinking_per_m_tok"), -}); - -export type ModelPriceRow = typeof modelPrice.$inferSelect; - -/** - * One row per message run's stage timing (CL-6257). `messageRunId` is - * unique for the same restart-safe reason `usage_turn.turnId` is: a - * dispatch retry after a crash must never double-count a run. Every - * `*At` column but `receivedAt`/`replyPostedAt` is nullable — a stage - * that never ran (a warm session skips `reactorStartAt`) is an honest - * null, never a fabricated timestamp. - */ -export const turnLatency = insightsSchema.table( - "turn_latency", - { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - sessionId: text("session_id").notNull(), - messageId: text("message_id").notNull(), - messageRunId: text("message_run_id").notNull(), - status: text("status").notNull(), - receivedAt: timestamp("received_at", { withTimezone: true }).notNull(), - reactorStartAt: timestamp("reactor_start_at", { withTimezone: true }), - inferenceStartAt: timestamp("inference_start_at", { withTimezone: true }), - firstTokenAt: timestamp("first_token_at", { withTimezone: true }), - replyPostedAt: timestamp("reply_posted_at", { - withTimezone: true, - }).notNull(), - recordedAt: timestamp("recorded_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [ - uniqueIndex("turn_latency_message_run_id_uidx").on(table.messageRunId), - ], -); - -export type TurnLatencyRow = typeof turnLatency.$inferSelect; diff --git a/packages/insights/src/store.ts b/packages/insights/src/store.ts deleted file mode 100644 index fbdb56c9e5..0000000000 --- a/packages/insights/src/store.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { TokenClasses, TokenRates } from "./pricing"; - -export type UsageTurnRecord = { - readonly id: string; - readonly tenantId: string; - readonly sessionId: string; - readonly turnId: string; - readonly provider: string | null; - readonly model: string; - readonly tokens: TokenClasses; - readonly reportedCostUsd: number | null; - readonly recordedAt: Date; -}; - -export type ModelPriceRecord = { - readonly model: string; - readonly rates: TokenRates; -}; - -export type InsertUsageInput = { - readonly id: string; - readonly tenantId: string; - readonly sessionId: string; - readonly turnId: string; - readonly provider?: string; - readonly model: string; - readonly tokens: TokenClasses; - readonly reportedCostUsd?: number; - readonly recordedAt?: Date; -}; - -export type UsageStore = { - /** Insert a turn. Returns the row, or null if turnId already exists (idempotent). */ - insertUsage(input: InsertUsageInput): Promise; - /** - * Rows for every tenant in `tenantIds` — the DB-layer aggregation seam a - * multi-tenant scope (a workspace parent plus its child workbenches) - * sums over, so a query never has to fan out into one browser fetch per - * tenant. A single-element array is the plain single-tenant read. - */ - listUsageByTenants( - tenantIds: readonly string[], - opts?: { from?: Date; to?: Date }, - ): Promise; - getPrice(model: string): Promise; - listPrices(): Promise; - upsertPrice(price: ModelPriceRecord): Promise; -}; - -/** - * In-memory store for unit tests and local smoke. Production wiring - * uses a drizzle-backed implementation against the same interface. - */ -export function createMemoryUsageStore( - seedPrices: readonly ModelPriceRecord[] = [], -): UsageStore { - const turns = new Map(); - const byTurnId = new Map(); - const prices = new Map( - seedPrices.map((p) => [p.model, p]), - ); - - return { - async insertUsage(input) { - if (byTurnId.has(input.turnId)) return null; - const record: UsageTurnRecord = { - id: input.id, - tenantId: input.tenantId, - sessionId: input.sessionId, - turnId: input.turnId, - provider: input.provider ?? null, - model: input.model, - tokens: input.tokens, - reportedCostUsd: input.reportedCostUsd ?? null, - recordedAt: input.recordedAt ?? new Date(), - }; - turns.set(record.id, record); - byTurnId.set(record.turnId, record.id); - return record; - }, - async listUsageByTenants(tenantIds, opts) { - const scope = new Set(tenantIds); - return [...turns.values()] - .filter((t) => scope.has(t.tenantId)) - .filter((t) => - opts?.from === undefined ? true : t.recordedAt >= opts.from, - ) - .filter((t) => - opts?.to === undefined ? true : t.recordedAt <= opts.to, - ) - .sort((a, b) => a.recordedAt.getTime() - b.recordedAt.getTime()); - }, - async getPrice(model) { - return prices.get(model) ?? null; - }, - async listPrices() { - return [...prices.values()]; - }, - async upsertPrice(price) { - prices.set(price.model, price); - return price; - }, - }; -} diff --git a/packages/insights/src/trace-reader.ts b/packages/insights/src/trace-reader.ts deleted file mode 100644 index 4eddb9f934..0000000000 --- a/packages/insights/src/trace-reader.ts +++ /dev/null @@ -1,232 +0,0 @@ -// Drizzle-backed RunTraceReader over the platform's own workflow_run / -// inference_turn / turn_part tables. No new storage: inference_turn and -// turn_part are the rows @intx/hub-sessions' event-collector already -// writes for every turn a run takes (chat or workflow-triggered alike — -// inference_turn.runId names the driving workflow_run regardless of -// origin). workflow_run_execution, despite existing in the schema, has no -// writer anywhere in the platform today, so it is not read here: reading -// it would render every run's trace empty forever, the same silent gap -// this reader replaces. -// -// turn_part has no index on turn_id today, so this reader's per-run query -// load depends on that index existing upstream. Tracked as an upstream ask -// and explicitly out of scope here — no migration added in this package. -import { and, asc, eq, inArray } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { inferenceTurn, turnPart, workflowRun } from "@intx/db/schema"; - -import type { RunTrace, RunTraceReader, RunTraceSpan } from "./queries"; - -type ToolPartMetadata = - | { kind: "call"; callId: string; name: string; arguments: unknown } - | { kind: "result"; callId: string; content: unknown; isError: boolean }; - -function isToolPartMetadata(value: unknown): value is ToolPartMetadata { - if (typeof value !== "object" || value === null) return false; - const kind = (value as { kind?: unknown }).kind; - return kind === "call" || kind === "result"; -} - -type TurnPartRow = { - id: string; - turnId: string; - type: string; - content: string | null; - metadata: unknown; - ordinal: number; -}; - -/** - * Positions a sub-turn span within its enclosing turn's real - * [start, end] wall-clock window. turn_part carries no timestamp of its - * own, only an `ordinal` — so position is derived from event sequence, - * clamped to the turn's own span rather than a fabricated instant outside - * it. - */ -function positionInTurn( - ordinal: number, - turnStart: number, - turnEnd: number, -): number { - return Math.min(turnStart + ordinal, turnEnd); -} - -function errorSpans( - parts: readonly TurnPartRow[], - turnStart: number, - turnEnd: number, -): RunTraceSpan[] { - return parts - .filter( - (part): part is TurnPartRow & { content: string } => - typeof part.content === "string", - ) - .map((part) => { - const at = positionInTurn(part.ordinal, turnStart, turnEnd); - return { - id: part.id, - label: "Error", - kind: "error", - start: at, - end: at, - durationMs: null, - tokens: null, - phase: "failed", - error: part.content, - timingSource: "ordinal", - }; - }); -} - -/** - * Pairs "call" and "result" tool parts by callId within a single turn and - * emits one span per tool call. A call with no matching result yet (the - * turn is still running) renders as `awaiting`, never a fabricated - * completion. - */ -function toolCallSpans( - parts: readonly TurnPartRow[], - turnStart: number, - turnEnd: number, -): RunTraceSpan[] { - const calls = new Map< - string, - { partId: string; name: string; ordinal: number } - >(); - const results = new Map(); - - for (const part of parts) { - if (!isToolPartMetadata(part.metadata)) continue; - if (part.metadata.kind === "call") { - calls.set(part.metadata.callId, { - partId: part.id, - name: part.metadata.name, - ordinal: part.ordinal, - }); - } else { - results.set(part.metadata.callId, { - content: part.metadata.content, - isError: part.metadata.isError, - }); - } - } - - const spans: RunTraceSpan[] = []; - for (const [callId, call] of calls) { - const result = results.get(callId); - const at = positionInTurn(call.ordinal, turnStart, turnEnd); - spans.push({ - id: call.partId, - label: call.name, - kind: "tool", - start: at, - end: at, - durationMs: null, - tokens: null, - phase: - result === undefined ? "awaiting" : result.isError ? "failed" : "ok", - error: - result?.isError === true - ? typeof result.content === "string" - ? result.content - : JSON.stringify(result.content) - : null, - // See RunTraceSpan.authz doc comment: verdicts live only in the - // sidecar-side git audit trail, unreachable from here today. - authz: null, - timingSource: "ordinal", - }); - } - return spans; -} - -/** - * Reads a run's trace off the platform's own tables: one span per - * inference_turn (real started_at/ended_at timing, the same rows the - * event-collector writes as each turn opens and settles), plus tool-call - * and error sub-spans pulled from that turn's turn_part rows (positioned - * by event seq, since turn_part carries no timestamp of its own). A run - * outside this tenant, or with no matching workflow_run row, reads as - * absent (null) — never an empty trace standing in for "not found". A run - * that exists but has taken no turns yet reads as an empty span list, not - * absent. - */ -export function createDrizzleRunTraceReader(db: DB["db"]): RunTraceReader { - return { - async getTrace(tenantId, runId): Promise { - const run = await db.query.workflowRun.findFirst({ - where: and( - eq(workflowRun.id, runId), - eq(workflowRun.tenantId, tenantId), - ), - }); - if (run === undefined) return null; - - const turns = await db.query.inferenceTurn.findMany({ - where: and( - eq(inferenceTurn.runId, runId), - eq(inferenceTurn.tenantId, tenantId), - ), - orderBy: asc(inferenceTurn.startedAt), - }); - - const spans: RunTraceSpan[] = []; - - const allParts = - turns.length === 0 - ? [] - : await db.query.turnPart.findMany({ - where: inArray( - turnPart.turnId, - turns.map((turn) => turn.id), - ), - orderBy: asc(turnPart.ordinal), - }); - const partsByTurnId = new Map(); - for (const part of allParts) { - const bucket = partsByTurnId.get(part.turnId); - if (bucket === undefined) { - partsByTurnId.set(part.turnId, [part]); - } else { - bucket.push(part); - } - } - for (const bucket of partsByTurnId.values()) { - bucket.sort((a, b) => a.ordinal - b.ordinal); - } - - for (const [index, turn] of turns.entries()) { - const start = turn.startedAt.getTime(); - const end = turn.endedAt?.getTime() ?? Date.now(); - const parts = partsByTurnId.get(turn.id) ?? []; - const errorParts = parts.filter((part) => part.type === "error"); - - spans.push({ - id: turn.id, - label: `Turn ${index + 1}`, - kind: "turn", - start, - end, - durationMs: turn.endedAt === null ? null : end - start, - tokens: null, - phase: - turn.status === "completed" - ? "ok" - : turn.status === "failed" - ? "failed" - : "awaiting", - error: - turn.status === "failed" ? (errorParts[0]?.content ?? null) : null, - timingSource: "measured", - }); - - spans.push(...toolCallSpans(parts, start, end)); - spans.push(...errorSpans(errorParts, start, end)); - } - - spans.sort((a, b) => a.start - b.start); - - return { runId, spans }; - }, - }; -} diff --git a/packages/insights/src/turn-part-write-guard.test.ts b/packages/insights/src/turn-part-write-guard.test.ts deleted file mode 100644 index f7f84e7623..0000000000 --- a/packages/insights/src/turn-part-write-guard.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, test, beforeEach } from "bun:test"; -import { turnPart, inferenceTurn } from "@intx/db/schema"; -import type { DB } from "@intx/db"; - -import { - withTurnPartPersistGuard, - turnPartPersistFailures, -} from "./turn-part-write-guard"; - -class FakePostgresError extends Error { - code: string; - constructor(code: string, message: string) { - super(message); - this.code = code; - } -} - -// Mirrors drizzle-orm's own DrizzleQueryError: `.message` is just the query -// text and param list, the real Postgres error sits on `.cause`. See -// drizzle-orm/errors.js. -class FakeDrizzleQueryError extends Error { - constructor(cause: unknown) { - super('Failed query: insert into "turn_part" (...)\nparams: ...'); - this.cause = cause; - } -} - -function fakeDb( - valuesImpl: (values: unknown, call: number) => Promise, -): { db: DB["db"]; calls: unknown[] } { - const calls: unknown[] = []; - const db = { - insert: () => ({ - values: (values: unknown) => { - calls.push(values); - return valuesImpl(values, calls.length); - }, - }), - } as unknown as DB["db"]; - return { db, calls }; -} - -describe("withTurnPartPersistGuard", () => { - beforeEach(() => { - turnPartPersistFailures.reset(); - }); - - test("retries once and succeeds on a transient turn_id FK race", async () => { - const { db, calls } = fakeDb(async (_values, call) => { - if (call === 1) { - throw new FakeDrizzleQueryError( - new FakePostgresError( - "23503", - 'insert or update on table "turn_part" violates foreign key constraint "turn_part_turn_id_inference_turn_id_fk"', - ), - ); - } - return { ok: true }; - }); - const wrapped = withTurnPartPersistGuard(db); - - const result: unknown = await wrapped.insert(turnPart).values({ - id: "part_1", - turnId: "turn_1", - sessionId: "session_1", - type: "tool", - ordinal: 0, - }); - - expect(result).toEqual({ ok: true }); - expect(calls).toHaveLength(2); - expect(turnPartPersistFailures.count).toBe(0); - }); - - test("logs the real cause and counts the loss when the retry also fails", async () => { - const cause = new FakePostgresError( - "23503", - 'insert or update on table "turn_part" violates foreign key constraint "turn_part_turn_id_inference_turn_id_fk"', - ); - const { db } = fakeDb(async () => { - throw new FakeDrizzleQueryError(cause); - }); - const wrapped = withTurnPartPersistGuard(db); - - await expect( - wrapped.insert(turnPart).values({ - id: "part_1", - turnId: "turn_1", - sessionId: "session_1", - type: "tool", - ordinal: 0, - }), - ).rejects.toThrow(); - - expect(turnPartPersistFailures.count).toBe(1); - }); - - test("counts and rethrows a non-FK failure without retrying", async () => { - const { db, calls } = fakeDb(async () => { - throw new FakeDrizzleQueryError( - new FakePostgresError( - "23502", - "null value in column violates not-null constraint", - ), - ); - }); - const wrapped = withTurnPartPersistGuard(db); - - await expect( - wrapped.insert(turnPart).values({ - id: "part_1", - turnId: "turn_1", - sessionId: "session_1", - type: "tool", - ordinal: 0, - }), - ).rejects.toThrow(); - - expect(calls).toHaveLength(1); - expect(turnPartPersistFailures.count).toBe(1); - }); - - test("does not touch inserts into other tables", async () => { - const { db, calls } = fakeDb(async () => ({ ok: true })); - const wrapped = withTurnPartPersistGuard(db); - - await wrapped.insert(inferenceTurn).values({ - id: "turn_1", - sessionId: "session_1", - runId: "run_1", - tenantId: "tenant_1", - model: "claude", - startedAt: new Date(), - }); - - expect(calls).toHaveLength(1); - expect(turnPartPersistFailures.count).toBe(0); - }); -}); diff --git a/packages/insights/src/turn-part-write-guard.ts b/packages/insights/src/turn-part-write-guard.ts deleted file mode 100644 index 512aa06ec4..0000000000 --- a/packages/insights/src/turn-part-write-guard.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Guards `turn_part` inserts against a race in the vendored event -// collector (`@intx/hub-sessions`, see VENDORED.md): `createEventCollector` -// dispatches InferenceEvents without serializing per-agent processing, so a -// turn_part insert referencing a just-created `inference_turn`/`agent_session` -// row can reach Postgres on a different pooled connection before that -// parent row's own INSERT commits — a transient 23503 -// foreign_key_violation, not a real data-shape problem. -// -// Drizzle's own `DrizzleQueryError.message` is just the query text and -// param list ("Failed query: ...\nparams: ..."); the real Postgres error -// sits on `.cause`. The registry's `dispatch()` catch logs only -// `err.message` and drops `.cause`, so today every one of these races is an -// invisible WRN with no diagnosable detail and a permanently lost part — -// see the "hub·event-collector-registry: Failed to persist event" reports. -// -// This wraps the `db` handle passed to `createEventCollectorRegistry` so a -// `turn_part` insert retries once on that specific race (the parent row -// commits within milliseconds), and — win or lose — a genuine loss is never -// just a swallowed WRN again: the real cause is logged at error level and -// counted. Only `turn_part` inserts are touched; every other table's insert -// passes through untouched. -import { turnPart } from "@intx/db/schema"; -import { pgErrorCode, PG_FOREIGN_KEY_VIOLATION } from "@intx/db"; -import type { DB } from "@intx/db"; -import { getLogger } from "@intx/log"; - -const log = getLogger(["insights", "turn-part-write-guard"]); - -const RETRY_DELAY_MS = 25; - -/** Process-lifetime count of turn_part inserts lost for good, after any - * retry — the audit-visible counterpart to the error log below. */ -export const turnPartPersistFailures = { - count: 0, - reset(): void { - this.count = 0; - }, -}; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function causeMessage(err: unknown): string { - const cause = err instanceof Error ? err.cause : undefined; - return cause instanceof Error ? cause.message : "no cause captured"; -} - -type InsertBuilder = { values: (...args: unknown[]) => Promise }; - -export function withTurnPartPersistGuard(db: DB["db"]): DB["db"] { - return new Proxy(db as object, { - get(target, prop, receiver) { - if (prop !== "insert") { - return Reflect.get(target, prop, receiver); - } - const insert = Reflect.get(target, prop, target) as ( - table: unknown, - ) => InsertBuilder; - return (table: unknown) => { - const builder = insert.call(target, table); - if (table !== turnPart) { - return builder; - } - // The event-collector always calls `.values(...)` directly and - // awaits the result without further chaining, so only that method - // needs interception here. - const originalValues = builder.values.bind(builder); - return { - values: async (values: unknown): Promise => { - try { - return await originalValues(values); - } catch (err) { - if (pgErrorCode(err) !== PG_FOREIGN_KEY_VIOLATION) { - turnPartPersistFailures.count += 1; - log.error`turn_part insert failed: ${err instanceof Error ? err.message : String(err)} (cause: ${causeMessage(err)})`; - throw err; - } - await sleep(RETRY_DELAY_MS); - try { - return await originalValues(values); - } catch (retryErr) { - turnPartPersistFailures.count += 1; - log.error`turn_part insert lost after retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)} (cause: ${causeMessage(retryErr)})`; - throw retryErr; - } - } - }, - }; - }; - }, - }) as DB["db"]; -} diff --git a/packages/insights/test/insights-scope-verification.test.ts b/packages/insights/test/insights-scope-verification.test.ts deleted file mode 100644 index 94acd05f8a..0000000000 --- a/packages/insights/test/insights-scope-verification.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -// Critique verification for cl-6099-insights-rollup. -// 1. createPostgresUsageStore.listUsageByTenants: the ANY(array) SQL path -// has no coverage on the branch (routes-scope.test.ts substitutes the -// memory store). Proves multi-tenant reads, isolation, empty-array, -// and that a quote-bearing tenant id is parameterized (no injection). -// 2. resolveScope recursion: a workbench with a workbench child tenancy is -// NOT a leaf — its /usage now includes the grandchild's rows, and the -// workspace parent's aggregate includes them too. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import postgres from "postgres"; - -import { createDB, schema } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { setupDatabase } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { createInsightsRoutes } from "../src/routes"; -import { createPostgresUsageStore } from "../src/pg-store"; -import { createMemoryUsageStore } from "../src/store"; -import type { OverallUsageSummary } from "../src/queries"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_critique_scope_verification`; - return url.toString(); -} - -function dbConfigFromUrl(u: string) { - const url = new URL(u); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const tokens = (input: number, output: number) => ({ - input, - cacheRead: 0, - cacheWrite: 0, - output, - thinking: 0, -}); - -describeIfDb("pg-store listUsageByTenants (real Postgres)", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - let pg: ReturnType; - - beforeAll(async () => { - const m = new URL(scratchUrl); - m.pathname = "/postgres"; - const maintenance = postgres(m.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl); - pg = createPostgresUsageStore(scratchUrl); - }, 60000); - - afterAll(async () => { - await pg.close(); - const m = new URL(scratchUrl); - m.pathname = "/postgres"; - const maintenance = postgres(m.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("multi-tenant ANY() returns exactly the scoped tenants' rows", async () => { - await pg.store.insertUsage({ - id: generateId("inferenceTurn"), - tenantId: "wb-a", - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: tokens(10, 0), - }); - await pg.store.insertUsage({ - id: generateId("inferenceTurn"), - tenantId: "wb-b", - sessionId: "s2", - turnId: "t2", - model: "m", - tokens: tokens(0, 20), - }); - await pg.store.insertUsage({ - id: generateId("inferenceTurn"), - tenantId: "wb-other", - sessionId: "s3", - turnId: "t3", - model: "m", - tokens: tokens(999, 999), - }); - - const both = await pg.store.listUsageByTenants(["wb-a", "wb-b"]); - expect(both.map((r) => r.tenantId).sort()).toEqual(["wb-a", "wb-b"]); - - const single = await pg.store.listUsageByTenants(["wb-a"]); - expect(single).toHaveLength(1); - }); - - test("provider and reported cost survive the Postgres round trip", async () => { - const inserted = await pg.store.insertUsage({ - id: generateId("inferenceTurn"), - tenantId: "wb-priced", - sessionId: "s-priced", - turnId: "t-priced", - provider: "anthropic", - model: "claude-sonnet-5", - tokens: tokens(12, 34), - reportedCostUsd: 0.000364, - }); - - expect(inserted).toMatchObject({ - provider: "anthropic", - reportedCostUsd: 0.000364, - }); - const [listed] = await pg.store.listUsageByTenants(["wb-priced"]); - expect(listed).toMatchObject({ - provider: "anthropic", - reportedCostUsd: 0.000364, - }); - }); - - test("empty scope returns no rows", async () => { - expect(await pg.store.listUsageByTenants([])).toEqual([]); - }); - - test("quote-bearing tenant id is parameterized, matches nothing, throws nothing", async () => { - const rows = await pg.store.listUsageByTenants([ - "x'; DROP TABLE insights.usage_turn; --", - ]); - expect(rows).toEqual([]); - // Table still there. - expect(await pg.store.listUsageByTenants(["wb-a"])).toHaveLength(1); - }); -}); - -describeIfDb("resolveScope recursion over workbench child tenancies", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ).replace("_critique_scope_verification", "_critique_recursion_verification"); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - const workspaceId = generateId("tenant"); - const workbenchId = generateId("tenant"); - const workbenchTenantId = generateId("tenant"); - - const allowAll: RequireGrant = () => async (_c, next) => { - await next(); - }; - const store = createMemoryUsageStore(); - - beforeAll(async () => { - const m = new URL(scratchUrl); - m.pathname = "/postgres"; - const maintenance = postgres(m.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl); - - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - await db.insert(schema.tenant).values({ - id: workspaceId, - name: "Workspace", - slug: `crit-ws-${workspaceId}`, - domain: `crit-ws-${workspaceId}.localhost`, - }); - await db.insert(schema.tenant).values({ - id: workbenchId, - name: "Bench", - slug: `crit-wb-${workbenchId}`, - domain: `crit-wb-${workbenchId}.localhost`, - parentId: workspaceId, - }); - // Same shape packages/chat's workbench tenancy mint produces: - // a tenant whose parent is the workbench (chat routes.ts:837). - await db.insert(schema.tenant).values({ - id: workbenchTenantId, - name: "chn_deadbeef", - slug: `crit-ch-${workbenchTenantId}`, - domain: `crit-ch-${workbenchTenantId}.localhost`, - parentId: workbenchId, - }); - } finally { - await close(); - } - - await store.insertUsage({ - id: "wbrow", - tenantId: workbenchId, - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: tokens(100, 0), - }); - await store.insertUsage({ - id: "chrow", - tenantId: workbenchTenantId, - sessionId: "s2", - turnId: "t2", - model: "m", - tokens: tokens(7, 0), - }); - }, 60000); - - afterAll(async () => { - const m = new URL(scratchUrl); - m.pathname = "/postgres"; - const maintenance = postgres(m.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - function appFor(tenantId: string, db: ReturnType["db"]) { - const routes = createInsightsRoutes({ store, requireGrant: allowAll, db }); - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", { - id: tenantId, - name: "n", - slug: "s", - domain: "d", - parentId: tenantId === workspaceId ? null : workspaceId, - config: null, - createdAt: new Date(), - updatedAt: new Date(), - } as never); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - return app; - } - - test("a workbench with a workbench child tenancy is not a leaf: its /usage includes the grandchild's rows", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const res = await appFor(workbenchId, db).request("/usage"); - const summary = (await res.json()) as OverallUsageSummary; - // 107 = workbench's own 100 + workbench tenant's 7. The branch test's - // "a leaf has no descendants" claim does not hold for a workbench - // that has minted a workbench tenancy. - expect(summary.tokens.input).toBe(107); - expect(summary.turns).toBe(2); - } finally { - await close(); - } - }); - - test("workspace aggregate includes grandchildren (recursive, not direct children only)", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const res = await appFor(workspaceId, db).request("/usage"); - const summary = (await res.json()) as OverallUsageSummary; - expect(summary.tokens.input).toBe(107); - } finally { - await close(); - } - }); -}); diff --git a/packages/insights/test/migrations.test.ts b/packages/insights/test/migrations.test.ts deleted file mode 100644 index 8a1420f49a..0000000000 --- a/packages/insights/test/migrations.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring @corbits/chat's migrations test. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyInsightsMigrations } from "../src/migrations"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_insights_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const migrationNames = [ - "0001_usage_turn", - "0002_model_price", - "0003_turn_latency", - "0004_usage_turn_provider_cost", -]; - -describeIfDb("applyInsightsMigrations", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("applies both tables into their own schema and is idempotent on a second run", async () => { - const first = await applyInsightsMigrations(scratchUrl); - expect(first.applied).toEqual(migrationNames); - - const second = await applyInsightsMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied.sort()).toEqual([...migrationNames].sort()); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'insights' AND table_name IN ` + - `('usage_turn', 'model_price', 'turn_latency')`, - ); - expect(tables.map((row) => String(row["table_name"])).sort()).toEqual([ - "model_price", - "turn_latency", - "usage_turn", - ]); - - const inPublic = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'public' AND table_name IN ` + - `('usage_turn', 'model_price', 'turn_latency')`, - ); - expect(inPublic).toHaveLength(0); - - const indexes = await sql.unsafe( - `SELECT indexname FROM pg_indexes WHERE schemaname = 'insights' AND tablename = 'usage_turn'`, - ); - expect(indexes.map((row) => String(row["indexname"]))).toContain( - "usage_turn_turn_id_uidx", - ); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/insights/test/routes-run-trace.test.ts b/packages/insights/test/routes-run-trace.test.ts deleted file mode 100644 index 15cc13f682..0000000000 --- a/packages/insights/test/routes-run-trace.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable. Proves the mounted -// /runs/:runId/trace route — createInsightsRoutes wired with a real -// createDrizzleRunTraceReader, the same composition apps/hub/src/index.ts -// uses — returns real spans read off the platform's own workflow_run / -// inference_turn / turn_part tables, not the "reader not mounted" absent -// envelope. trace-reader.test.ts already proves the reader function itself -// in isolation; this proves the HTTP route built on top of it. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import postgres from "postgres"; - -import { createDB, schema } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { setupDatabase } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { createInsightsRoutes } from "../src/routes"; -import { createDrizzleRunTraceReader } from "../src/trace-reader"; -import { createMemoryUsageStore } from "../src/store"; -import type { RunTrace } from "../src/queries"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_insights_routes_run_trace_test`; - return url.toString(); -} - -// Parse DATABASE_URL the same way the hub does (apps/hub/src/index.ts): an -// empty user falls through to the postgres client's OS-username default. -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -describeIfDb("createInsightsRoutes /runs/:runId/trace", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - const tenantId = generateId("tenant"); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - - // The same path the hub boots with: the platform's own migrations, - // then every installed package's. - await setupDatabase(scratchUrl); - - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - await db.insert(schema.tenant).values({ - id: tenantId, - name: `Insights Routes Trace Test ${tenantId}`, - slug: `insights-routes-trace-${tenantId}`, - domain: `insights-routes-trace-${tenantId}.localhost`, - }); - } finally { - await close(); - } - // `setupDatabase` applies the platform's migrations plus every - // installed package's; under `bun run test`'s cross-package - // concurrency that comfortably exceeds bun:test's default 5s hook - // timeout, so it gets an explicit one here rather than a flaky suite. - }, 30000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("returns real spans with correct timingSource for a completed run", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const definitionId = generateId("workflowDefinition"); - const runId = generateId("workflowRun"); - const turnId = generateId("inferenceTurn"); - const sessionId = generateId("session"); - const principalId = generateId("principal"); - - const startedAt = new Date("2026-08-01T00:00:00.000Z"); - const endedAt = new Date("2026-08-01T00:00:05.000Z"); - - await db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId, - name: "Echo workflow", - }); - await db.insert(schema.workflowRun).values({ - id: runId, - definitionId, - tenantId, - status: "completed", - createdAt: startedAt, - }); - await db.insert(schema.principal).values({ - id: principalId, - tenantId, - kind: "agent", - refId: "not-a-real-agent-instance", - status: "active", - }); - await db.insert(schema.agentSession).values({ - id: sessionId, - tenantId, - agentId: definitionId, - principalId, - status: "active", - }); - await db.insert(schema.inferenceTurn).values({ - id: turnId, - sessionId, - runId, - tenantId, - model: "noop-model", - status: "completed", - startedAt, - endedAt, - }); - await db.insert(schema.turnPart).values({ - id: generateId("turnPart"), - turnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "call", - callId: "call-1", - name: "echo", - arguments: { message: "hi" }, - }, - ordinal: 1, - }); - - const routes = createInsightsRoutes({ - store: createMemoryUsageStore(), - requireGrant: allowAll, - runTraceReader: createDrizzleRunTraceReader(db), - }); - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", { id: tenantId } as never); - c.set("principal", { id: principalId } as never); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - - const response = await app.request(`/runs/${runId}/trace`); - expect(response.status).toBe(200); - - const body = (await response.json()) as RunTrace; - expect(body.runId).toBe(runId); - expect("absent" in body).toBe(false); - expect(body.spans).not.toBeNull(); - - const spans = body.spans ?? []; - const turnSpan = spans.find((s) => s.kind === "turn"); - expect(turnSpan).toMatchObject({ - id: turnId, - phase: "ok", - durationMs: 5000, - timingSource: "measured", - }); - - const toolSpan = spans.find((s) => s.kind === "tool"); - expect(toolSpan).toMatchObject({ - label: "echo", - timingSource: "ordinal", - }); - } finally { - await close(); - } - }, 30000); -}); diff --git a/packages/insights/test/routes-scope.test.ts b/packages/insights/test/routes-scope.test.ts deleted file mode 100644 index 6f27ad8672..0000000000 --- a/packages/insights/test/routes-scope.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable. Proves the -// workspace-rollup contract createInsightsRoutes' /usage, /activity, -// /tools, and /scope routes carry when `deps.db` is wired the same way -// apps/hub/src/index.ts wires it: a parent tenant's usage/activity -// aggregate equals the sum of its child workbenches' own numbers, and a -// child workbench's own route still returns just its own numbers in this -// fixture, where childA/childB have no children of their own — not a -// general "workbenches are leaves" claim: a workbench that has minted a -// workbench child tenancy has descendants too, and its /usage rolls those -// up the same way (see the recursion coverage in -// insights-scope-verification.test.ts). /scope proves the read-only -// counterpart a switcher reads: parent identity, own identity, and the -// sibling workbench list — filtered to tenants the caller actually holds -// a principal in, never a sibling the caller has no membership in. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import postgres from "postgres"; - -import { createDB, schema } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { setupDatabase } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { createInsightsRoutes } from "../src/routes"; -import { createMemoryUsageStore } from "../src/store"; -import type { OverallUsageSummary } from "../src/queries"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_insights_routes_scope_test`; - return url.toString(); -} - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -describeIfDb("createInsightsRoutes workspace rollup (deps.db wired)", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - const parentId = generateId("tenant"); - const childAId = generateId("tenant"); - const childBId = generateId("tenant"); - const unrelatedId = generateId("tenant"); - const memberOfAUserId = `usr_${childAId}`; - const memberOfParentUserId = `usr_${parentId}`; - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - - await setupDatabase(scratchUrl); - - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - await db.insert(schema.tenant).values({ - id: parentId, - name: "Acme workspace", - slug: `insights-scope-parent-${parentId}`, - domain: `insights-scope-parent-${parentId}.localhost`, - }); - await db.insert(schema.tenant).values([ - { - id: childAId, - name: "Acme — Support", - slug: `insights-scope-child-a-${childAId}`, - domain: `insights-scope-child-a-${childAId}.localhost`, - parentId, - }, - { - id: childBId, - name: "Acme — Sales", - slug: `insights-scope-child-b-${childBId}`, - domain: `insights-scope-child-b-${childBId}.localhost`, - parentId, - }, - ]); - await db.insert(schema.tenant).values({ - id: unrelatedId, - name: "Unrelated workbench", - slug: `insights-scope-unrelated-${unrelatedId}`, - domain: `insights-scope-unrelated-${unrelatedId}.localhost`, - }); - // memberOfAUserId is a member of childA only — never granted a - // principal in childB, the parent, or the unrelated tenant. - await db.insert(schema.principal).values({ - id: generateId("principal"), - tenantId: childAId, - kind: "user", - refId: memberOfAUserId, - status: "active", - }); - // memberOfParentUserId is a member of the parent workspace and both - // its child workbenches — the "workspace member" shape. - await db.insert(schema.principal).values([ - { - id: generateId("principal"), - tenantId: parentId, - kind: "user", - refId: memberOfParentUserId, - status: "active", - }, - { - id: generateId("principal"), - tenantId: childAId, - kind: "user", - refId: memberOfParentUserId, - status: "active", - }, - { - id: generateId("principal"), - tenantId: childBId, - kind: "user", - refId: memberOfParentUserId, - status: "active", - }, - ]); - } finally { - await close(); - } - }, 30000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - function appFor( - tenantId: string, - db: ReturnType["db"], - callerUserId: string | null = null, - ) { - const routes = createInsightsRoutes({ - store, - requireGrant: allowAll, - db, - }); - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", { - id: tenantId, - name: tenantNames[tenantId], - slug: "s", - domain: "d", - parentId: tenantId === parentId ? null : parentId, - config: null, - createdAt: new Date(), - updatedAt: new Date(), - } as never); - c.set( - "user", - callerUserId === null - ? null - : ({ - id: callerUserId, - createdAt: new Date(), - updatedAt: new Date(), - email: `${callerUserId}@example.test`, - emailVerified: true, - name: callerUserId, - } as never), - ); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - return app; - } - - const tenantNames: Record = { - [parentId]: "Acme workspace", - [childAId]: "Acme — Support", - [childBId]: "Acme — Sales", - [unrelatedId]: "Unrelated workbench", - }; - - const store = createMemoryUsageStore(); - - test("parent aggregate equals the sum of its child workbenches, per-workbench stays isolated", async () => { - await store.insertUsage({ - id: "u1", - tenantId: childAId, - sessionId: "s1", - turnId: "t1", - model: "m", - tokens: { - input: 100, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "u2", - tenantId: childBId, - sessionId: "s2", - turnId: "t2", - model: "m", - tokens: { - input: 0, - cacheRead: 0, - cacheWrite: 0, - output: 250, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "u3", - tenantId: unrelatedId, - sessionId: "s3", - turnId: "t3", - model: "m", - tokens: { - input: 9999, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const parentResponse = await appFor(parentId, db).request("/usage"); - expect(parentResponse.status).toBe(200); - const parentSummary = - (await parentResponse.json()) as OverallUsageSummary; - - const aResponse = await appFor(childAId, db).request("/usage"); - const aSummary = (await aResponse.json()) as OverallUsageSummary; - const bResponse = await appFor(childBId, db).request("/usage"); - const bSummary = (await bResponse.json()) as OverallUsageSummary; - - expect(aSummary.turns).toBe(1); - expect(aSummary.tokens.input).toBe(100); - expect(bSummary.turns).toBe(1); - expect(bSummary.tokens.output).toBe(250); - - // Parent (workspace) aggregate = sum of its child workbenches. - expect(parentSummary.turns).toBe(aSummary.turns + bSummary.turns); - expect(parentSummary.tokens.input).toBe( - aSummary.tokens.input + bSummary.tokens.input, - ); - expect(parentSummary.tokens.output).toBe( - aSummary.tokens.output + bSummary.tokens.output, - ); - // The unrelated root tenant never leaks into the parent's rollup. - expect(parentSummary.tokens.input).toBe(100); - } finally { - await close(); - } - }); - - test("/scope reports parent identity and every sibling workbench for a workspace member, and no parent for the workspace itself", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const childScopeResponse = await appFor( - childAId, - db, - memberOfParentUserId, - ).request("/scope"); - expect(childScopeResponse.status).toBe(200); - const childScope = (await childScopeResponse.json()) as { - tenantId: string; - name: string; - parent: { tenantId: string; name: string } | null; - workbenches: { tenantId: string; name: string }[]; - }; - expect(childScope.tenantId).toBe(childAId); - expect(childScope.parent).toEqual({ - tenantId: parentId, - name: "Acme workspace", - }); - expect(childScope.workbenches.map((w) => w.tenantId).sort()).toEqual( - [childAId, childBId].sort(), - ); - - const parentScopeResponse = await appFor( - parentId, - db, - memberOfParentUserId, - ).request("/scope"); - const parentScope = (await parentScopeResponse.json()) as { - parent: unknown; - workbenches: { tenantId: string; name: string }[]; - }; - expect(parentScope.parent).toBeNull(); - expect(parentScope.workbenches).toEqual([ - { tenantId: parentId, name: "Acme workspace" }, - ]); - } finally { - await close(); - } - }); - - test("/scope never discloses a sibling or parent the caller has no principal in", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - // memberOfAUserId belongs to childA only, not to childB or the - // parent — even though the request targets childA (a tenant the - // caller IS a member of), the response must carry no trace of - // childB or the parent. - const response = await appFor(childAId, db, memberOfAUserId).request( - "/scope", - ); - expect(response.status).toBe(200); - const body = (await response.json()) as { - parent: { tenantId: string; name: string } | null; - workbenches: { tenantId: string; name: string }[]; - }; - expect(body.parent).toBeNull(); - expect(body.workbenches).toEqual([ - { tenantId: childAId, name: "Acme — Support" }, - ]); - const raw = JSON.stringify(body); - expect(raw).not.toContain(childBId); - expect(raw).not.toContain("Acme — Sales"); - expect(raw).not.toContain(parentId); - expect(raw).not.toContain("Acme workspace"); - } finally { - await close(); - } - }); - - test("/workbenches splits the parent's aggregate back out per child, named and ranked by turns", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - await store.insertUsage({ - id: "w1", - tenantId: childAId, - sessionId: "sw1", - turnId: "tw1", - model: "m", - tokens: { - input: 1, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "w2", - tenantId: childBId, - sessionId: "sw2", - turnId: "tw2", - model: "m", - tokens: { - input: 1, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - await store.insertUsage({ - id: "w3", - tenantId: childBId, - sessionId: "sw3", - turnId: "tw3", - model: "m", - tokens: { - input: 1, - cacheRead: 0, - cacheWrite: 0, - output: 0, - thinking: 0, - }, - }); - - const response = await appFor(parentId, db).request("/workbenches"); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { tenantId: string; name: string; turns: number }[]; - }; - // Ranked by turns descending — childB has recorded more turns than - // childA across this describe block's fixtures. The parent itself - // is the team space, not a workbench (CL-6368) — it never appears - // as a row here, even though it is the tenant this rollup was - // requested against. - expect(body.items.map((i) => i.tenantId)).toEqual([childBId, childAId]); - expect(body.items.find((i) => i.tenantId === childBId)?.name).toBe( - "Acme — Sales", - ); - expect(body.items.some((i) => i.tenantId === parentId)).toBe(false); - // The unrelated root tenant never appears in the parent's scope. - expect(body.items.some((i) => i.tenantId === unrelatedId)).toBe(false); - - const leafResponse = await appFor(childAId, db).request("/workbenches"); - const leafBody = (await leafResponse.json()) as { - items: { tenantId: string; turns: number }[]; - }; - // A leaf workbench (no descendants) returns just its own row. - expect(leafBody.items).toHaveLength(1); - expect(leafBody.items[0]?.tenantId).toBe(childAId); - } finally { - await close(); - } - }); - - test("/scope with no authenticated caller falls back to self only, discloses nothing else", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const response = await appFor(childAId, db, null).request("/scope"); - expect(response.status).toBe(200); - const body = (await response.json()) as { - parent: unknown; - workbenches: { tenantId: string; name: string }[]; - }; - expect(body.parent).toBeNull(); - expect(body.workbenches).toEqual([ - { tenantId: childAId, name: "Acme — Support" }, - ]); - } finally { - await close(); - } - }); -}); diff --git a/packages/insights/test/trace-reader.test.ts b/packages/insights/test/trace-reader.test.ts deleted file mode 100644 index 04d1ea5923..0000000000 --- a/packages/insights/test/trace-reader.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable. Proves -// createDrizzleRunTraceReader reads real rows back off the platform's own -// workflow_run / workflow_run_execution / inference_turn / turn_part -// tables — the same rows apps/hub/src/routine-run-summary.ts and -// @intx/hub-sessions' event-collector already read and write — rather than -// a re-parsed git log or a fabricated trace. Runs against its own scratch -// database, never the developer's or the walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { createDB, schema } from "@intx/db"; -import { generateId } from "@intx/hub-common"; - -import { setupDatabase } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { createDrizzleRunTraceReader } from "../src/trace-reader"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_insights_trace_reader_test`; - return url.toString(); -} - -// Parse DATABASE_URL the same way the hub does (apps/hub/src/index.ts): an -// empty user falls through to the postgres client's OS-username default. -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("createDrizzleRunTraceReader", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - const tenantId = generateId("tenant"); - const otherTenantId = generateId("tenant"); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - - // The same path the hub boots with: the platform's own migrations, - // then every installed package's. - await setupDatabase(scratchUrl); - - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - for (const id of [tenantId, otherTenantId]) { - await db.insert(schema.tenant).values({ - id, - name: `Trace Reader Test ${id}`, - slug: `trace-reader-${id}`, - domain: `trace-reader-${id}.localhost`, - }); - } - } finally { - await close(); - } - // `setupDatabase` applies the platform's migrations plus every - // installed package's; under `bun run test`'s cross-package - // concurrency that comfortably exceeds bun:test's default 5s hook - // timeout, so it gets an explicit one here rather than a flaky suite. - }, 30000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("a run with no workflow_run row reads as absent, never an empty trace", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const reader = createDrizzleRunTraceReader(db); - const trace = await reader.getTrace(tenantId, generateId("workflowRun")); - expect(trace).toBeNull(); - } finally { - await close(); - } - }); - - test("a run in another tenant reads as absent", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const definitionId = generateId("workflowDefinition"); - const runId = generateId("workflowRun"); - await db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId: otherTenantId, - name: "Other tenant's workflow", - }); - await db.insert(schema.workflowRun).values({ - id: runId, - definitionId, - tenantId: otherTenantId, - status: "completed", - }); - - const reader = createDrizzleRunTraceReader(db); - const trace = await reader.getTrace(tenantId, runId); - expect(trace).toBeNull(); - } finally { - await close(); - } - }); - - test("a run that exists but never executed reads as an empty (not absent) trace", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const definitionId = generateId("workflowDefinition"); - const runId = generateId("workflowRun"); - await db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId, - name: "Deployed, never triggered", - }); - await db.insert(schema.workflowRun).values({ - id: runId, - definitionId, - tenantId, - status: "deployed", - }); - - const reader = createDrizzleRunTraceReader(db); - const trace = await reader.getTrace(tenantId, runId); - expect(trace).not.toBeNull(); - expect(trace?.spans).toEqual([]); - } finally { - await close(); - } - }); - - test("returns turn spans with real timing, plus tool-call and error sub-spans from turn_part", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const definitionId = generateId("workflowDefinition"); - const runId = generateId("workflowRun"); - const turnId = generateId("inferenceTurn"); - const sessionId = generateId("session"); - - const startedAt = new Date("2026-08-01T00:00:00.000Z"); - const endedAt = new Date("2026-08-01T00:00:05.000Z"); - - await db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId, - name: "Echo workflow", - }); - await db.insert(schema.workflowRun).values({ - id: runId, - definitionId, - tenantId, - status: "running", - createdAt: startedAt, - }); - const principalId = generateId("principal"); - await db.insert(schema.principal).values({ - id: principalId, - tenantId, - kind: "agent", - refId: "not-a-real-agent-instance", - status: "active", - }); - await db.insert(schema.agentSession).values({ - id: sessionId, - tenantId, - agentId: definitionId, - principalId, - status: "active", - }); - await db.insert(schema.inferenceTurn).values({ - id: turnId, - sessionId, - runId, - tenantId, - model: "noop-model", - status: "completed", - startedAt, - endedAt, - }); - await db.insert(schema.turnPart).values([ - { - id: generateId("turnPart"), - turnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "call", - callId: "call-1", - name: "echo", - arguments: { message: "hi" }, - }, - ordinal: 1, - }, - { - id: generateId("turnPart"), - turnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "result", - callId: "call-1", - content: "hi", - isError: false, - }, - ordinal: 2, - }, - { - id: generateId("turnPart"), - turnId, - sessionId, - type: "error", - content: "boom", - metadata: { category: "reactor_error" }, - ordinal: 3, - }, - ]); - - const reader = createDrizzleRunTraceReader(db); - const trace = await reader.getTrace(tenantId, runId); - expect(trace).not.toBeNull(); - expect(trace?.runId).toBe(runId); - - const turnSpan = trace?.spans.find((s) => s.kind === "turn"); - expect(turnSpan).toMatchObject({ - id: turnId, - label: "Turn 1", - phase: "ok", - durationMs: 5000, - error: null, - timingSource: "measured", - }); - expect(turnSpan?.start).toBe(startedAt.getTime()); - expect(turnSpan?.end).toBe(endedAt.getTime()); - - const toolSpan = trace?.spans.find((s) => s.kind === "tool"); - expect(toolSpan).toMatchObject({ - label: "echo", - phase: "ok", - error: null, - // Documented gap: authz verdicts are not reachable from the - // hub's Postgres-only composition root today. - authz: null, - timingSource: "ordinal", - }); - - const errorSpan = trace?.spans.find((s) => s.kind === "error"); - expect(errorSpan).toMatchObject({ - phase: "failed", - error: "boom", - timingSource: "ordinal", - }); - } finally { - await close(); - } - }); - - test("batches turn_part reads across turns without cross-turn bleeding", async () => { - const { db, close } = createDB(dbConfigFromUrl(scratchUrl)); - try { - const definitionId = generateId("workflowDefinition"); - const runId = generateId("workflowRun"); - const sessionId = generateId("session"); - const firstTurnId = generateId("inferenceTurn"); - const secondTurnId = generateId("inferenceTurn"); - - const firstStart = new Date("2026-08-02T00:00:00.000Z"); - const firstEnd = new Date("2026-08-02T00:00:05.000Z"); - const secondStart = new Date("2026-08-02T00:00:10.000Z"); - const secondEnd = new Date("2026-08-02T00:00:15.000Z"); - - await db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId, - name: "Two-turn workflow", - }); - await db.insert(schema.workflowRun).values({ - id: runId, - definitionId, - tenantId, - status: "running", - createdAt: firstStart, - }); - const principalId = generateId("principal"); - await db.insert(schema.principal).values({ - id: principalId, - tenantId, - kind: "agent", - refId: "not-a-real-agent-instance-batching", - status: "active", - }); - await db.insert(schema.agentSession).values({ - id: sessionId, - tenantId, - agentId: definitionId, - principalId, - status: "active", - }); - await db.insert(schema.inferenceTurn).values([ - { - id: firstTurnId, - sessionId, - runId, - tenantId, - model: "noop-model", - status: "completed", - startedAt: firstStart, - endedAt: firstEnd, - }, - { - id: secondTurnId, - sessionId, - runId, - tenantId, - model: "noop-model", - status: "completed", - startedAt: secondStart, - endedAt: secondEnd, - }, - ]); - await db.insert(schema.turnPart).values([ - { - id: generateId("turnPart"), - turnId: firstTurnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "call", - callId: "first-call", - name: "first-tool", - arguments: {}, - }, - ordinal: 2, - }, - { - id: generateId("turnPart"), - turnId: firstTurnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "result", - callId: "first-call", - content: "ok", - isError: false, - }, - ordinal: 1, - }, - { - id: generateId("turnPart"), - turnId: secondTurnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "call", - callId: "second-call", - name: "second-tool", - arguments: {}, - }, - ordinal: 1, - }, - { - id: generateId("turnPart"), - turnId: secondTurnId, - sessionId, - type: "tool", - content: null, - metadata: { - kind: "result", - callId: "second-call", - content: "ok", - isError: false, - }, - ordinal: 2, - }, - ]); - - const reader = createDrizzleRunTraceReader(db); - const trace = await reader.getTrace(tenantId, runId); - expect(trace).not.toBeNull(); - - const toolSpans = trace?.spans.filter((s) => s.kind === "tool") ?? []; - expect(toolSpans).toHaveLength(2); - - const firstToolSpan = toolSpans.find((s) => s.label === "first-tool"); - const secondToolSpan = toolSpans.find((s) => s.label === "second-tool"); - expect(firstToolSpan).toMatchObject({ phase: "ok" }); - expect(secondToolSpan).toMatchObject({ phase: "ok" }); - - // Each tool span is positioned within its own turn's window, never - // bleeding into the other turn's [start, end] range. - expect(firstToolSpan?.start).toBeGreaterThanOrEqual(firstStart.getTime()); - expect(firstToolSpan?.start).toBeLessThanOrEqual(firstEnd.getTime()); - expect(secondToolSpan?.start).toBeGreaterThanOrEqual( - secondStart.getTime(), - ); - expect(secondToolSpan?.start).toBeLessThanOrEqual(secondEnd.getTime()); - } finally { - await close(); - } - }); -}); diff --git a/packages/insights/tsconfig.json b/packages/insights/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/insights/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/scripts/checks/browser-safe-subpaths.ts b/scripts/checks/browser-safe-subpaths.ts index 82cc947505..02f000c82d 100644 --- a/scripts/checks/browser-safe-subpaths.ts +++ b/scripts/checks/browser-safe-subpaths.ts @@ -51,7 +51,6 @@ export interface BrowserSafeEntry { */ export const ENTRIES: readonly BrowserSafeEntry[] = [ { package: "@corbits/inbox", subpath: "./client" }, - { package: "@corbits/insights", subpath: "./client" }, { package: "@corbits/agent-directory", subpath: "./client" }, { package: "@corbits/api-query", subpath: "." }, // The envelope alone (UnauthenticatedError, ApiQueryError, toAPIQuery) has @@ -184,7 +183,7 @@ function splitPackageSpecifier(specifier: string): { /** * `./client` is this repo's naming convention for a browser-safe subpath - * (inbox, insights, agent-directory, presence, + * (inbox, agent-directory, presence, bench, * preferences all use it) — a package that declares one is making the same * "nothing server-only reaches here" promise ENTRIES exists to check, so a * declared `./client` with no ENTRIES ruling is itself a violation, not a diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index 6b8fc3768b..4d96e418dd 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -97,11 +97,20 @@ const ALLOWLIST: readonly { tables: ["notify_dispatch"], }, { - // `turn_latency` (CL-6257) records per-message-run latency stages — - // product observability the platform's own tables never capture. - relPath: "packages/insights/src/schema.ts", - maxOccurrences: 3, - tables: ["usage_turn", "model_price", "turn_latency"], + relPath: "packages/bench/src/schema.ts", + maxOccurrences: 1, + tables: ["bench_settings"], + }, + { + // A bench's model policy: its allow/deny selectors, price ceilings + // and provider preference. The platform's catalog owns what a bench + // can reach; what a bench is willing to spend on it is a product + // decision with nowhere native to live. Everything else this package + // answers is derived at read time from model_offering and + // model_pricing — see docs/inference-concepts.md. + relPath: "packages/inference-catalog/src/schema.ts", + maxOccurrences: 1, + tables: ["bench_model_policy"], }, { relPath: "packages/onboarding/src/schema.ts", diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index f8d217958b..acbba83bfe 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -35,7 +35,6 @@ import { applyInboxMigrations, applyMailboxMigrations, } from "../packages/inbox/src/migrations"; -import { applyInsightsMigrations } from "../packages/insights/src/migrations"; import { applyAgentDirectoryMigrations } from "../packages/agent-directory/src/migrations"; import { applyOnboardingMigrations } from "../packages/onboarding/src/migrations"; @@ -61,7 +60,6 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { { name: "@corbits/mailbox", apply: applyMailboxMigrations }, // CL-7208's snooze-until table, own schema — see packages/inbox/src/schema.ts. { name: "@corbits/inbox", apply: applyInboxMigrations }, - { name: "@corbits/insights", apply: applyInsightsMigrations }, { name: "@corbits/agent-directory", apply: applyAgentDirectoryMigrations }, { name: "@workbench/onboarding", apply: applyOnboardingMigrations }, ]; @@ -102,6 +100,7 @@ async function applyInstalledPackageMigrations( await dropSchemaIfPresent(databaseUrl, "run_key_history"); await dropSchemaIfPresent(databaseUrl, "preferences"); await dropSchemaIfPresent(databaseUrl, "bench"); + await dropSchemaIfPresent(databaseUrl, "insights"); } // --- hub-resolved platform dependencies ------------------------------ @@ -395,11 +394,14 @@ async function dropRoutinesSchemaAfterDigestHandoff( /** * One-shot, forward-only: `@corbits/run-key-history` and - * `@corbits/preferences` (CL-8158), and `@corbits/bench` (CL-8160), had - * zero web callers on their mounts — a diagnostics-only listener and two - * clients that were built but never imported — so those packages, their - * mounts, and their schemas are gone. Absent schema is a no-op; safe to - * re-run. + * `@corbits/preferences` (CL-8158) had zero web callers on their mounts — + * a diagnostics-only listener and a client that was built but never + * imported — so both packages, their mounts, and their schemas are gone. + * `bench` (CL-8160) joins them for the same reason. `insights` (also + * CL-8160) joins them too, for a different reason: the hub mounts nothing + * Workbench-specific, so its usage/latency writers and read routes are + * gone — Insights UI now reads stock observability/workflow routes + * client-side instead. Absent schema is a no-op; safe to re-run. */ async function dropSchemaIfPresent( databaseUrl: string, diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts index b2ff84f7d6..50093a595e 100644 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -617,32 +617,6 @@ async function main(): Promise { return (raw as { runIds: string[] }).runIds; } - /** - * The number of completed per-message brackets the hub has durably - * recorded for this tenant. `@corbits/insights`' latency tracker opens - * a row on the `message.run.started` AGENT event and commits it on - * `message.run.ended`, so a non-zero sample count is durable, - * HTTP-observable evidence that the bracket both opened and closed — - * the only such evidence a folded `step`-mode turn produces. - */ - async function completedTurnBrackets(): Promise { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/insights/latency`, - undefined, - admin.cookies, - ); - expectStatus("read the turn-latency summary", res, 200); - const total = (res.data as { total?: { samples?: unknown } }).total; - if (typeof total?.samples !== "number") { - throw new Error( - `the latency summary carries no total.samples: ${JSON.stringify(res.data)}`, - ); - } - return total.samples; - } - async function listAgentMessages(): Promise<{ id: string; text: string }[]> { const res = await api( hub.baseUrl, @@ -1125,40 +1099,6 @@ async function main(): Promise { ), ); - // The step shape's per-message bracket, asserted where a real turn has - // definitely happened. `@corbits/insights`' latency tracker opens its - // row on `message.run.started` and commits it on `message.run.ended`, - // so a committed sample IS the agent event reaching the hub — durably, - // over HTTP — which is the honest step-mode analogue of the section - // shape's `RunStarted`. - await hop( - "PROOF 2 (step mode) — the turn's message.run.started/ended bracket is durably recorded", - async () => { - // The latency summary is grant-gated and the seed's grant set does - // not cover it; planting it here keeps this a real read of the real - // route rather than a skipped check. - await plantGrant("insights:*", "read"); - - const deadline = Date.now() + 60_000; - for (;;) { - const brackets = await completedTurnBrackets(); - if (brackets > 0) { - console.log( - ` TRANSCRIPT — completed per-message brackets: ${String(brackets)}`, - ); - return; - } - if (Date.now() > deadline) { - throw new Error( - "a real reply landed but insights recorded zero turn-latency " + - "samples, so no message.run.started/ended pair reached the hub", - ); - } - await Bun.sleep(2000); - } - }, - ); - // ---- proof 4: kill the sidecar mid-turn, restart, keep talking ---- await hop("PROOF 4 — kill the sidecar mid-turn", async () => { const sent = await api(