From 7b22e2eb1295a3a0cec5896c8fb964bad548e3d6 Mon Sep 17 00:00:00 2001 From: ru-code-dev <53821477+ru-code-dev@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:58:11 -0400 Subject: [PATCH] feat(ru-fork): stats analytics panel CLI usage analytics from qwen transcripts: server scanner + durable SQLite cache, web dashboard, shared CLI runtime-path resolver, brand-neutral telemetry matching, and resilient (drop-and-recompute) cache reads. --- .../Layers/ProjectionStatsFileCache.ts | 121 ++++++++ apps/server/src/persistence/Migrations.ts | 2 + .../src/persistence/Migrations/032_Stats.ts | 22 ++ .../persistence/Services/StatsFileCache.ts | 45 +++ .../src/ru-fork/common/cliRuntimeRoots.ts | 67 +++++ .../src/ru-fork/qwen-transcript/paths.ts | 65 +--- apps/server/src/ru-fork/stats/StatsLayers.ts | 13 + apps/server/src/ru-fork/stats/StatsScanner.ts | 231 +++++++++++++++ apps/server/src/ru-fork/stats/aggregate.ts | 228 ++++++++++++++ apps/server/src/ru-fork/stats/paths.ts | 40 +++ .../src/ru-fork/stats/serviceSignatures.ts | 34 +++ apps/server/src/ru-fork/stats/telemetry.ts | 149 ++++++++++ apps/server/src/server.ts | 5 + .../src/textGeneration/CliTextGeneration.ts | 6 +- .../textGeneration/TextGenerationPrompts.ts | 5 +- .../server/src/textGeneration/instructions.ts | 14 + apps/server/src/ws.ts | 15 + .../persistence/Layers/StatsFileCache.test.ts | 144 +++++++++ .../ru-fork/common/cliRuntimeRoots.test.ts | 116 ++++++++ .../ru-fork/qwen-transcript/paths.test.ts | 89 +----- .../tests/ru-fork/stats/aggregate.test.ts | 192 ++++++++++++ apps/server/tests/ru-fork/stats/paths.test.ts | 37 +++ .../ru-fork/stats/serviceSignatures.test.ts | 22 ++ .../tests/ru-fork/stats/statsScanner.test.ts | 279 ++++++++++++++++++ .../tests/ru-fork/stats/telemetry.test.ts | 137 +++++++++ apps/web/src/rpc/wsRpcClient.ts | 12 + .../stats/components/RefreshControl.tsx | 44 +-- .../stats/components/SessionDetailSheet.tsx | 6 +- .../stats/components/StatsDashboard.tsx | 22 +- .../stats/components/StatsFilterBar.tsx | 133 +++++---- .../ru-fork/stats/components/primitives.tsx | 2 +- .../stats/components/widgets/ModelsCard.tsx | 4 +- .../components/widgets/SessionsTableCard.tsx | 11 +- apps/web/src/ru-fork/stats/index.ts | 8 +- apps/web/src/ru-fork/stats/model/catalog.ts | 104 ++----- apps/web/src/ru-fork/stats/model/fakeData.ts | 56 ---- .../src/ru-fork/stats/model/filterOptions.ts | 79 +++++ apps/web/src/ru-fork/stats/model/format.ts | 8 +- .../ru-fork/stats/model/generateSessions.ts | 164 ---------- apps/web/src/ru-fork/stats/model/selectors.ts | 239 ++++++++++----- apps/web/src/ru-fork/stats/model/types.ts | 61 ++-- apps/web/src/ru-fork/stats/store.ts | 77 +++-- apps/web/src/ru-fork/stats/useStatsData.ts | 68 +++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/rpc.ts | 23 ++ packages/contracts/src/ru-fork/stats.ts | 106 +++++++ stats/audit.md | 194 ++++++++++++ stats/backend.md | 264 +++++++++++++++++ stats/frontend.md | 210 +++++++++++++ stats/stats-architecture.md | 227 ++++++++++++++ 50 files changed, 3511 insertions(+), 691 deletions(-) create mode 100644 apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts create mode 100644 apps/server/src/persistence/Migrations/032_Stats.ts create mode 100644 apps/server/src/persistence/Services/StatsFileCache.ts create mode 100644 apps/server/src/ru-fork/common/cliRuntimeRoots.ts create mode 100644 apps/server/src/ru-fork/stats/StatsLayers.ts create mode 100644 apps/server/src/ru-fork/stats/StatsScanner.ts create mode 100644 apps/server/src/ru-fork/stats/aggregate.ts create mode 100644 apps/server/src/ru-fork/stats/paths.ts create mode 100644 apps/server/src/ru-fork/stats/serviceSignatures.ts create mode 100644 apps/server/src/ru-fork/stats/telemetry.ts create mode 100644 apps/server/src/textGeneration/instructions.ts create mode 100644 apps/server/tests/persistence/Layers/StatsFileCache.test.ts create mode 100644 apps/server/tests/ru-fork/common/cliRuntimeRoots.test.ts create mode 100644 apps/server/tests/ru-fork/stats/aggregate.test.ts create mode 100644 apps/server/tests/ru-fork/stats/paths.test.ts create mode 100644 apps/server/tests/ru-fork/stats/serviceSignatures.test.ts create mode 100644 apps/server/tests/ru-fork/stats/statsScanner.test.ts create mode 100644 apps/server/tests/ru-fork/stats/telemetry.test.ts delete mode 100644 apps/web/src/ru-fork/stats/model/fakeData.ts create mode 100644 apps/web/src/ru-fork/stats/model/filterOptions.ts delete mode 100644 apps/web/src/ru-fork/stats/model/generateSessions.ts create mode 100644 apps/web/src/ru-fork/stats/useStatsData.ts create mode 100644 packages/contracts/src/ru-fork/stats.ts create mode 100644 stats/audit.md create mode 100644 stats/backend.md create mode 100644 stats/frontend.md create mode 100644 stats/stats-architecture.md diff --git a/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts b/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts new file mode 100644 index 00000000000..bead158bfdc --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts @@ -0,0 +1,121 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { NonNegativeInt, StatsSession } from "@t3tools/contracts"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + MarkAbsentInput, + StatsFileCacheRepository, + StatsFileCacheRow, + type StatsFileCacheRepositoryShape, +} from "../Services/StatsFileCache.ts"; + +// `present` is a 0/1 INTEGER column (SQLite has no boolean) → converted to boolean +// by rowToCacheRow, exactly like McpCatalog's locked/enabled/trust. session_json +// decodes into a StatsSession. +const StatsFileCacheDbRow = StatsFileCacheRow.mapFields( + Struct.assign({ + present: NonNegativeInt, + session: Schema.fromJsonString(StatsSession), + }), +); +type StatsFileCacheDbRow = typeof StatsFileCacheDbRow.Type; + +function rowToCacheRow(row: StatsFileCacheDbRow): StatsFileCacheRow { + return { ...row, present: row.present !== 0 }; +} + +const makeStatsFileCacheRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const selectRows = () => + sql` + SELECT file_path AS "filePath", mtime_ms AS "mtimeMs", size_bytes AS "sizeBytes", + present, last_seen_at AS "lastSeenAt", session_json AS "session" + FROM stats_file_cache + ORDER BY file_path ASC + `; + + const decodeRow = Schema.decodeUnknownEffect(StatsFileCacheDbRow); + + // Decode each row INDIVIDUALLY: one undecodable session_json — a row written by an + // older StatsSession shape, or a corrupt blob — is dropped + logged rather than + // failing the whole read. The cache is rebuildable, so refresh recomputes any dropped + // file on its next scan. Without this, a single stale row wedges BOTH getSnapshot and + // refresh (refresh reads the cache before it can overwrite anything). + const listAll = () => + Effect.gen(function* () { + const rawRows = yield* selectRows(); + const decoded = yield* Effect.forEach(rawRows, (rawRow) => + decodeRow(rawRow).pipe( + Effect.map((row) => Option.some(rowToCacheRow(row))), + Effect.catch(() => Effect.succeed(Option.none())), + ), + ); + const rows = decoded.filter(Option.isSome).map((entry) => entry.value); + const droppedCount = decoded.length - rows.length; + if (droppedCount > 0) { + yield* Effect.logError("[stats] dropped undecodable cache rows (recomputed on next refresh)", { + dropped: droppedCount, + }); + } + return rows; + }).pipe(Effect.mapError(toPersistenceSqlError("StatsFileCache.listAll"))); + + const upsertRow = SqlSchema.void({ + Request: StatsFileCacheRow, + execute: (row) => + sql` + INSERT INTO stats_file_cache ( + file_path, mtime_ms, size_bytes, present, last_seen_at, session_json + ) + VALUES ( + ${row.filePath}, ${row.mtimeMs}, ${row.sizeBytes}, ${row.present ? 1 : 0}, + ${row.lastSeenAt}, ${JSON.stringify(row.session)} + ) + ON CONFLICT (file_path) DO UPDATE SET + mtime_ms = excluded.mtime_ms, + size_bytes = excluded.size_bytes, + present = excluded.present, + last_seen_at = excluded.last_seen_at, + session_json = excluded.session_json + `, + }); + + const markAbsentRows = SqlSchema.void({ + Request: MarkAbsentInput, + execute: ({ filePaths, lastSeenAt }) => + sql` + UPDATE stats_file_cache + SET present = 0, last_seen_at = ${lastSeenAt} + WHERE file_path IN ${sql.in(filePaths)} + `, + }); + + return { + listAll, + upsert: (row) => + upsertRow(row).pipe(Effect.mapError(toPersistenceSqlError("StatsFileCache.upsert"))), + markAbsent: (input) => + (input.filePaths.length === 0 + ? Effect.void + : markAbsentRows(input) + ).pipe(Effect.mapError(toPersistenceSqlError("StatsFileCache.markAbsent"))), + removeByPaths: (filePaths) => + (filePaths.length === 0 + ? Effect.void + : sql`DELETE FROM stats_file_cache WHERE file_path IN ${sql.in(filePaths)}`.pipe(Effect.asVoid) + ).pipe(Effect.mapError(toPersistenceSqlError("StatsFileCache.removeByPaths"))), + } satisfies StatsFileCacheRepositoryShape; +}); + +export const StatsFileCacheRepositoryLive = Layer.effect( + StatsFileCacheRepository, + makeStatsFileCacheRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 93450185893..eb55f5e1ea3 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_Mcp.ts"; +import Migration0032 from "./Migrations/032_Stats.ts"; /** * Migration loader with all migrations defined inline. @@ -87,6 +88,7 @@ export const migrationEntries = [ [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "Mcp", Migration0031], + [32, "Stats", Migration0032], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/032_Stats.ts b/apps/server/src/persistence/Migrations/032_Stats.ts new file mode 100644 index 00000000000..d83cac9e911 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_Stats.ts @@ -0,0 +1,22 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +// ru-fork: the SINGLE migration for the Stats feature (unreleased — edit THIS file +// rather than stacking 033+). One row per scanned chat file, keyed by absolute +// file_path. mtime_ms + size_bytes are the change-detector (re-parse only on a +// mismatch). session_json holds the computed StatsSession. present = 0 means the +// source file is gone but its stats are retained (kept, never deleted). +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS stats_file_cache ( + file_path TEXT PRIMARY KEY, + mtime_ms INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + present INTEGER NOT NULL DEFAULT 1, + last_seen_at TEXT NOT NULL, + session_json TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Services/StatsFileCache.ts b/apps/server/src/persistence/Services/StatsFileCache.ts new file mode 100644 index 00000000000..03ca7c60f8d --- /dev/null +++ b/apps/server/src/persistence/Services/StatsFileCache.ts @@ -0,0 +1,45 @@ +import { StatsSession } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +/** One persisted cache row: a computed StatsSession + on-disk identity. */ +export const StatsFileCacheRow = Schema.Struct({ + filePath: Schema.String, + mtimeMs: Schema.Number, + sizeBytes: Schema.Number, + present: Schema.Boolean, + lastSeenAt: Schema.String, + session: StatsSession, +}); +export type StatsFileCacheRow = typeof StatsFileCacheRow.Type; + +export const MarkAbsentInput = Schema.Struct({ + filePaths: Schema.Array(Schema.String), + lastSeenAt: Schema.String, +}); +export type MarkAbsentInput = typeof MarkAbsentInput.Type; + +/** + * Persistence for the per-file stats cache (one row per chat file, keyed by + * absolute file_path). Reads/writes the computed StatsSession; never the raw + * transcript content. + */ +export interface StatsFileCacheRepositoryShape { + readonly listAll: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + readonly upsert: (row: StatsFileCacheRow) => Effect.Effect; + /** Flip present→0 (+ lastSeenAt) for files no longer on disk. Rows are kept. */ + readonly markAbsent: (input: MarkAbsentInput) => Effect.Effect; + /** Hard-delete rows by path (zero-usage "ghost" files that aren't real sessions). */ + readonly removeByPaths: (filePaths: ReadonlyArray) => Effect.Effect; +} + +export class StatsFileCacheRepository extends Context.Service< + StatsFileCacheRepository, + StatsFileCacheRepositoryShape +>()("ru-fork/persistence/Services/StatsFileCache/StatsFileCacheRepository") {} diff --git a/apps/server/src/ru-fork/common/cliRuntimeRoots.ts b/apps/server/src/ru-fork/common/cliRuntimeRoots.ts new file mode 100644 index 00000000000..d6aa6403301 --- /dev/null +++ b/apps/server/src/ru-fork/common/cliRuntimeRoots.ts @@ -0,0 +1,67 @@ +// @effect-diagnostics nodeBuiltinImport:off +// ru-fork: single source for the CLI's RUNTIME base dir + transcript tree layout. +// Both the advanced-chat transcript reader (qwen-transcript) and the global stats +// scanner resolve `/projects//chats/` from here, so the brand-coupled +// base-dir priority (and the QWEN_RUNTIME_DIR env name) can never drift between them. +// +// Mirrors qwen-code core/src/config/storage.ts (getRuntimeBaseDir / getProjectDir) +// and utils/paths.ts (sanitizeCwd) EXACTLY so our resolved path === the CLI's path. +// +// Base dir priority (storage.ts:102-118): +// QWEN_RUNTIME_DIR env > settings.advanced.runtimeOutputDir > getGlobalQwenDir() +// where getGlobalQwenDir() == os.homedir()/$CLI_DIR == ServerConfig.cliConfigDir. +import * as path from "node:path"; +import * as os from "node:os"; + +/** Directory segment under the runtime base that holds per-project transcript trees. */ +export const PROJECTS_DIRNAME = "projects"; +/** Directory segment under each project that holds the `.jsonl` chats. */ +export const CHATS_DIRNAME = "chats"; + +/** 1:1 port of qwen utils/paths.ts:218 sanitizeCwd. `platform` is injectable for tests. */ +export const sanitizeCwd = (cwd: string, platform: NodeJS.Platform = os.platform()): string => { + const normalized = platform === "win32" ? cwd.toLowerCase() : cwd; + return normalized.replace(/[^a-zA-Z0-9]/g, "-"); +}; + +/** + * 1:1 port of qwen storage.ts:40-67 resolveRuntimeBaseDir (tilde + relative). + * A relative `dir` resolves against `cwd` when a thread cwd is known (transcript + * reads), or against the home dir when it isn't (the global stats scan). + */ +export const expandRuntimeBaseDir = (dir: string, cwd?: string): string => { + let resolved = dir; + if (resolved === "~" || resolved.startsWith("~/") || resolved.startsWith("~\\")) { + const rest = + resolved === "~" ? [] : resolved.slice(2).split(/[/\\]+/).filter(Boolean); + resolved = path.join(os.homedir(), ...rest); + } + if (!path.isAbsolute(resolved)) { + resolved = path.resolve(cwd ?? os.homedir(), resolved); + } + return resolved; +}; + +export interface RuntimeBaseInput { + /** process env of THIS server (the child inherits it; QWEN_RUNTIME_DIR wins). */ + readonly env: NodeJS.ProcessEnv; + /** ServerConfig.cliConfigDir = {home}/$CLI_DIR = qwen's default runtime base. */ + readonly cliConfigDir: string; + /** Thread cwd when one is known (transcript reads); omitted for a global scan. */ + readonly cwd?: string | undefined; + /** advanced.runtimeOutputDir from the CLI settings.json, if any. */ + readonly runtimeOutputDirSetting?: string | undefined; +} + +/** Mirror of storage.ts:102-118 priority: env > runtimeOutputDir setting > cliConfigDir. */ +export const resolveRuntimeBaseDir = (input: RuntimeBaseInput): string => { + const fromEnv = input.env["QWEN_RUNTIME_DIR"]; + if (fromEnv && fromEnv.trim()) { + return expandRuntimeBaseDir(fromEnv.trim(), input.cwd); + } + const setting = input.runtimeOutputDirSetting?.trim(); + if (setting) { + return expandRuntimeBaseDir(setting, input.cwd); + } + return input.cliConfigDir; +}; diff --git a/apps/server/src/ru-fork/qwen-transcript/paths.ts b/apps/server/src/ru-fork/qwen-transcript/paths.ts index d81333efe90..5b673ecaaf1 100644 --- a/apps/server/src/ru-fork/qwen-transcript/paths.ts +++ b/apps/server/src/ru-fork/qwen-transcript/paths.ts @@ -1,63 +1,26 @@ // @effect-diagnostics nodeBuiltinImport:off -// ru-fork: deterministic resolver for qwen's on-disk transcript file. Mirrors -// qwen-code core/src/config/storage.ts (getRuntimeBaseDir / getProjectDir) and -// utils/paths.ts (sanitizeCwd) EXACTLY so our resolved path === the CLI's path. -// -// Base dir priority (storage.ts:102-118): -// QWEN_RUNTIME_DIR env > settings.advanced.runtimeOutputDir > getGlobalQwenDir() -// where getGlobalQwenDir() == os.homedir()/$CLI_DIR == ServerConfig.cliConfigDir. -// We use `cliConfigDir` for the default so the base tracks our resolved CLI -// config dir (the same root the skills scanner reads), never a literal ".qwen". +// ru-fork: deterministic resolver for qwen's on-disk transcript FILE. The runtime +// base-dir priority + sanitizeCwd live in common/cliRuntimeRoots.ts (shared with the +// stats scanner so they cannot drift); this module only shapes the per-thread path +// `/projects//chats/.jsonl`. import * as path from "node:path"; -import * as os from "node:os"; -/** 1:1 port of qwen utils/paths.ts:218 sanitizeCwd. `platform` is injectable for tests. */ -export const sanitizeCwd = (cwd: string, platform: NodeJS.Platform = os.platform()): string => { - const normalized = platform === "win32" ? cwd.toLowerCase() : cwd; - return normalized.replace(/[^a-zA-Z0-9]/g, "-"); -}; +import { + CHATS_DIRNAME, + PROJECTS_DIRNAME, + type RuntimeBaseInput, + resolveRuntimeBaseDir, + sanitizeCwd, +} from "../common/cliRuntimeRoots.ts"; -/** 1:1 port of qwen storage.ts:40-67 resolveRuntimeBaseDir (tilde + relative). */ -export const expandRuntimeBaseDir = (dir: string, cwd: string): string => { - let resolved = dir; - if (resolved === "~" || resolved.startsWith("~/") || resolved.startsWith("~\\")) { - const rest = - resolved === "~" ? [] : resolved.slice(2).split(/[/\\]+/).filter(Boolean); - resolved = path.join(os.homedir(), ...rest); - } - if (!path.isAbsolute(resolved)) { - resolved = path.resolve(cwd, resolved); - } - return resolved; -}; - -export interface TranscriptBaseInput { - /** process env of THIS server (the child inherits it; QWEN_RUNTIME_DIR wins). */ - readonly env: NodeJS.ProcessEnv; - /** ServerConfig.cliConfigDir = {home}/$CLI_DIR = qwen's default runtime base. */ - readonly cliConfigDir: string; - /** thread cwd (worktreePath ?? workspaceRoot). */ +export interface TranscriptBaseInput extends RuntimeBaseInput { + /** thread cwd (worktreePath ?? workspaceRoot) — always known for a transcript read. */ readonly cwd: string; - /** advanced.runtimeOutputDir from the CLI settings.json, if any. */ - readonly runtimeOutputDirSetting: string | undefined; } -/** Mirror of storage.ts:102-118 priority. */ -export const resolveTranscriptBaseDir = (input: TranscriptBaseInput): string => { - const fromEnv = input.env["QWEN_RUNTIME_DIR"]; - if (fromEnv && fromEnv.trim()) { - return expandRuntimeBaseDir(fromEnv.trim(), input.cwd); - } - const setting = input.runtimeOutputDirSetting?.trim(); - if (setting) { - return expandRuntimeBaseDir(setting, input.cwd); - } - return input.cliConfigDir; -}; - /** `/projects//chats`. */ export const resolveChatsDir = (input: TranscriptBaseInput): string => - path.join(resolveTranscriptBaseDir(input), "projects", sanitizeCwd(input.cwd), "chats"); + path.join(resolveRuntimeBaseDir(input), PROJECTS_DIRNAME, sanitizeCwd(input.cwd), CHATS_DIRNAME); /** `/.jsonl`. */ export const resolveTranscriptFilePath = ( diff --git a/apps/server/src/ru-fork/stats/StatsLayers.ts b/apps/server/src/ru-fork/stats/StatsLayers.ts new file mode 100644 index 00000000000..0af42aa2197 --- /dev/null +++ b/apps/server/src/ru-fork/stats/StatsLayers.ts @@ -0,0 +1,13 @@ +// ru-fork: Stats layer composition. The scanner + its file-cache repo. The repo's +// SqlClient and the scanner's FileSystem/Path/ServerConfig come from the shared +// server runtime graph (where this layer is provideMerged, next to MCP) — Stats is +// DB-backed, so it registers like McpRuntimeServicesLive, not like the pure-fs +// QwenTranscriptLive. +import * as Layer from "effect/Layer"; + +import { StatsFileCacheRepositoryLive } from "../../persistence/Layers/ProjectionStatsFileCache.ts"; +import { StatsScannerLive } from "./StatsScanner.ts"; + +export const StatsLive = StatsScannerLive.pipe( + Layer.provide(StatsFileCacheRepositoryLive), +); diff --git a/apps/server/src/ru-fork/stats/StatsScanner.ts b/apps/server/src/ru-fork/stats/StatsScanner.ts new file mode 100644 index 00000000000..80d264bd807 --- /dev/null +++ b/apps/server/src/ru-fork/stats/StatsScanner.ts @@ -0,0 +1,231 @@ +// ru-fork: the Stats engine. Two clearly-separated operations: +// getSnapshot — PURE READ: return the stored rows from the DB. Never scans disk, +// never parses, never writes. The panel's instant safety-net load. +// refresh — the ONLY disk-touching op: scan the projects root, re-parse only +// the changed files, save, return. Incremental → cheap on every call. +// One file going wrong (locked/unreadable) is logged and skipped; it never fails the +// refresh or poisons the snapshot. The cache table is the only write target. +import { IsoDateTime, type StatsSession, type StatsSnapshot } from "@t3tools/contracts"; +import { StatsError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../../config.ts"; +import { StatsFileCacheRepository } from "../../persistence/Services/StatsFileCache.ts"; +import type { StatsFileCacheRow } from "../../persistence/Services/StatsFileCache.ts"; +import { aggregateSession } from "./aggregate.ts"; +import { chatsDirFor, resolveProjectsRoot } from "./paths.ts"; +import { extractFileTelemetry } from "./telemetry.ts"; + +export interface StatsScannerShape { + /** Pure DB read — return the stored sessions. No disk scan, no parse, no writes. */ + readonly getSnapshot: () => Effect.Effect; + /** Scan disk, re-parse changed files, save, return. The only disk-touching op. */ + readonly refresh: () => Effect.Effect; +} + +export class StatsScanner extends Context.Service()( + "ru-fork/stats/StatsScanner", +) {} + +interface DiskFile { + readonly filePath: string; + readonly projectDir: string; + readonly fileSessionId: string; + readonly mtimeMs: number; + readonly sizeBytes: number; +} + +/** + * Presence + lastSeenAt live in the row's columns (markAbsent flips them without + * rewriting session_json), so reconcile them onto the stored session at read time. + * A present row's session is already authoritative and returned as-is. + */ +const reconcilePresence = (row: StatsFileCacheRow): StatsSession => + row.present ? row.session : { ...row.session, present: false, lastSeenAt: row.lastSeenAt }; + +const buildSnapshot = ( + rows: ReadonlyArray, + when: string, + scannedFiles: number, + parsedFiles: number, +): StatsSnapshot => ({ + sessions: rows.map(reconcilePresence), + generatedAt: IsoDateTime.make(when), + scannedFiles, + parsedFiles, +}); + +const makeStatsScanner = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const cache = yield* StatsFileCacheRepository; + + // The machine-local IANA zone — day/hour buckets use it so "today" means the viewer's + // local day (server and web run on the same machine ⇒ same zone). `Intl` (not `new + // Date()`, banned here) reads it without constructing a Date. + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Codebase idiom for "now as ISO" — identical to ws.ts:131. + const nowIso = (): Effect.Effect => Effect.map(DateTime.now, DateTime.formatIso); + + /** Enumerate `//chats/*.jsonl` with stat metadata. */ + const listDiskFiles = (): Effect.Effect> => + Effect.gen(function* () { + const projectsRoot = resolveProjectsRoot({ + env: process.env, + cliConfigDir: config.cliConfigDir, + }); + const rootExists = yield* fileSystem.exists(projectsRoot).pipe(Effect.orElseSucceed(() => false)); + if (!rootExists) return []; + const projectDirs = yield* fileSystem + .readDirectory(projectsRoot) + .pipe(Effect.orElseSucceed(() => [])); + const files: DiskFile[] = []; + for (const projectDir of projectDirs) { + const chatsDir = chatsDirFor(projectsRoot, projectDir); + const chatsExists = yield* fileSystem.exists(chatsDir).pipe(Effect.orElseSucceed(() => false)); + if (!chatsExists) continue; + const entries = yield* fileSystem.readDirectory(chatsDir).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + if (!entry.endsWith(".jsonl")) continue; + const filePath = path.join(chatsDir, entry); + const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.option); + if (Option.isNone(fileInfo)) continue; + files.push({ + filePath, + projectDir, + fileSessionId: entry.slice(0, -".jsonl".length), + // effect/FileSystem File.Info: mtime is Option, size is Size. + // Absent mtime → 0 (treated as "oldest", forces a (re)parse). + mtimeMs: Option.getOrElse( + Option.map(fileInfo.value.mtime, (modifiedAt) => modifiedAt.getTime()), + () => 0, + ), + sizeBytes: Number(fileInfo.value.size), + }); + } + } + return files; + }); + + // Read a file's text; a read failure (locked/unreadable) is logged at `error` and + // yields None → the caller skips it, keeping the previously stored row. + const readText = (file: DiskFile): Effect.Effect> => + fileSystem.readFileString(file.filePath).pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.logError("[stats] skipped unreadable file", { file: file.filePath, error }).pipe( + Effect.as(Option.none()), + ), + ), + ); + + // Outcome of looking at one changed file during a refresh. + type ParseOutcome = + | { readonly kind: "session"; readonly session: StatsSession } + | { readonly kind: "ghost" } // no api_response → no usage → not a real session + | { readonly kind: "skip" }; // unreadable → leave any prior row untouched + + const parseFile = (file: DiskFile, when: string): Effect.Effect => + readText(file).pipe( + Effect.map((text): ParseOutcome => { + if (Option.isNone(text)) return { kind: "skip" }; + const telemetry = extractFileTelemetry(text.value); + // A session with no successful response (empty file, or only tool_call/api_error) + // has no tokens and no model — not real usage. Skipped. + const hasResponse = telemetry.events.some((event) => event.kind === "api_response"); + if (!hasResponse) return { kind: "ghost" }; + return { + kind: "session", + session: aggregateSession({ + telemetry, + projectDir: file.projectDir, + fileSessionId: file.fileSessionId, + nowIso: when, + timeZone, + }), + }; + }), + ); + + const getSnapshot = (): Effect.Effect => + Effect.gen(function* () { + const when = yield* nowIso(); + const rows = yield* cache.listAll(); + return buildSnapshot(rows, when, 0, 0); + }).pipe( + Effect.tapError((error) => Effect.logError("[stats] getSnapshot read failed", { error })), + Effect.mapError((cause) => new StatsError({ detail: "Failed to read stats snapshot", cause })), + ); + + const refresh = (): Effect.Effect => + Effect.gen(function* () { + const when = yield* nowIso(); + yield* Effect.logDebug("[stats] refresh start"); + const diskFiles = yield* listDiskFiles(); + const cachedRows = yield* cache.listAll(); + const cachedByPath = new Map(cachedRows.map((row) => [row.filePath, row])); + const diskPaths = new Set(diskFiles.map((file) => file.filePath)); + + let parsedFiles = 0; + const ghostPaths: string[] = []; + for (const file of diskFiles) { + const existing = cachedByPath.get(file.filePath); + const unchanged = + existing !== undefined && + existing.present && + existing.mtimeMs === file.mtimeMs && + existing.sizeBytes === file.sizeBytes; + if (unchanged) continue; + const outcome = yield* parseFile(file, when); + if (outcome.kind === "skip") continue; // read failed → keep prior row + if (outcome.kind === "ghost") { + ghostPaths.push(file.filePath); // zero-usage session → not stored + continue; + } + yield* cache.upsert({ + filePath: file.filePath, + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes, + present: true, + lastSeenAt: when, + session: outcome.session, + }); + parsedFiles += 1; + } + + // Drop ghost rows that a prior refresh may have stored (file still on disk, so + // the retain-after-delete sweep below won't catch them). + yield* cache.removeByPaths(ghostPaths); + + // Retain-after-delete: rows whose file vanished → present = 0 (kept). + const vanished = cachedRows + .filter((row) => row.present && !diskPaths.has(row.filePath)) + .map((row) => row.filePath); + yield* cache.markAbsent({ filePaths: vanished, lastSeenAt: when }); + + const finalRows = yield* cache.listAll(); + yield* Effect.logDebug("[stats] refresh done", { + scanned: diskFiles.length, + parsed: parsedFiles, + ghosts: ghostPaths.length, + retained: vanished.length, + total: finalRows.length, + }); + return buildSnapshot(finalRows, when, diskFiles.length, parsedFiles); + }).pipe( + Effect.tapError((error) => Effect.logError("[stats] refresh failed", { error })), + Effect.mapError((cause) => new StatsError({ detail: "Failed to refresh stats", cause })), + ); + + return { getSnapshot, refresh } satisfies StatsScannerShape; +}); + +export const StatsScannerLive = Layer.effect(StatsScanner, makeStatsScanner); diff --git a/apps/server/src/ru-fork/stats/aggregate.ts b/apps/server/src/ru-fork/stats/aggregate.ts new file mode 100644 index 00000000000..68f68c48e58 --- /dev/null +++ b/apps/server/src/ru-fork/stats/aggregate.ts @@ -0,0 +1,228 @@ +// ru-fork: pure reduction of one file's telemetry into a StatsSession. No I/O. +// Every field the dashboard reads is computed here from real events only. +import { IsoDateTime, type StatsCategory, type StatsSession } from "@t3tools/contracts"; + +import { isTempCwd, projectLabelFor } from "./paths.ts"; +import { SERVICE_SIGNATURES } from "./serviceSignatures.ts"; +import type { FileTelemetry, TelemetryEvent } from "./telemetry.ts"; + +const SIDE_QUERY_PREFIX = "side-query:"; +const COMPRESS_PREFIX = "compress-"; +/** Real interactive turns use "########"; everything else is one-shot/auto. */ +const REAL_TURN_MARKER = "########"; + +interface MutableDayBucket { + input: number; + output: number; + thinking: number; + cached: number; + apiCalls: number; +} + +const MILLISECONDS_PER_DAY = 86_400_000; + +// Day/hour are bucketed in the viewer's timezone (passed in — the machine-local zone in +// production, "UTC" in tests). `Intl` (not `new Date()`, which the server bans) does the +// zone conversion; formatters are memoized per zone. The web computes the same keys from +// its own local clock — same machine ⇒ same zone ⇒ keys line up. +const dateFormatters = new Map(); +const hourFormatters = new Map(); +const dateFormatter = (timeZone: string): Intl.DateTimeFormat => { + const existing = dateFormatters.get(timeZone); + if (existing !== undefined) return existing; + const created = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }); + dateFormatters.set(timeZone, created); + return created; +}; +const hourFormatter = (timeZone: string): Intl.DateTimeFormat => { + const existing = hourFormatters.get(timeZone); + if (existing !== undefined) return existing; + const created = new Intl.DateTimeFormat("en-US", { timeZone, hour: "2-digit", hourCycle: "h23" }); + hourFormatters.set(timeZone, created); + return created; +}; + +/** Local calendar day of an ISO timestamp — "YYYY-MM-DD" (en-CA renders ISO order). + * Returns "" for an unparseable timestamp (caller skips it). */ +const dayOf = (timestamp: string, timeZone: string): string => { + const epochMs = Date.parse(timestamp); + return Number.isNaN(epochMs) ? "" : dateFormatter(timeZone).format(epochMs); +}; + +/** Local hour 0..23 of an ISO timestamp (0 for an unparseable timestamp). */ +const hourOf = (timestamp: string, timeZone: string): number => { + const epochMs = Date.parse(timestamp); + return Number.isNaN(epochMs) ? 0 : Number(hourFormatter(timeZone).format(epochMs)); +}; + +/** Weekday (Monday=0) of a "YYYY-MM-DD" key (calendar weekday, TZ-independent; + * derived from epoch days, 1970-01-01 = Thursday, to avoid `new Date()`). */ +const weekdayOfDayKey = (dayKey: string): number => { + const daysSinceEpoch = Math.floor(Date.parse(`${dayKey}T00:00:00Z`) / MILLISECONDS_PER_DAY); + const sundayBasedWeekday = (((daysSinceEpoch + 4) % 7) + 7) % 7; + return (sundayBasedWeekday + 6) % 7; +}; + +/** Distinct real-turn ids — only the "########" interactive turns count. */ +const countTurns = (events: ReadonlyArray): number => { + const turnIds = new Set(); + for (const event of events) { + if (event.kind !== "api_response") continue; + const promptId = event.promptId; + if (promptId === undefined || !promptId.includes(REAL_TURN_MARKER)) continue; + turnIds.add(promptId); + } + return turnIds.size; +}; + +/** Classify the file: dialog (has a real turn) → else qwen-internal by prompt_id → + * else one of our service prompts by content → else "service" (unknown one-shot). */ +const classifyCategory = (telemetry: FileTelemetry): StatsCategory => { + const promptIds: string[] = []; + for (const event of telemetry.events) { + if (event.kind === "api_response" && event.promptId !== undefined) promptIds.push(event.promptId); + } + if (promptIds.some((promptId) => promptId.includes(REAL_TURN_MARKER))) return "dialog"; + if (promptIds.some((promptId) => promptId.startsWith(SIDE_QUERY_PREFIX))) return "memory"; + if (promptIds.some((promptId) => promptId.startsWith(COMPRESS_PREFIX))) return "compress"; + if (promptIds.some((promptId) => promptId.includes("#"))) return "subagent"; + const userText = telemetry.firstUserText ?? ""; + for (const signature of SERVICE_SIGNATURES) { + if (userText.includes(signature.marker)) return signature.category; + } + return "service"; +}; + +const dominant = (counts: ReadonlyMap, fallback: string): string => { + let best = fallback; + let bestCount = -1; + for (const [key, count] of counts) { + if (count > bestCount) { + best = key; + bestCount = count; + } + } + return best; +}; + +export interface AggregateInput { + readonly telemetry: FileTelemetry; + /** Directory name under projects/ (the encoded cwd) — stable project key. */ + readonly projectDir: string; + /** File's session id (filename stem) when telemetry carries none. */ + readonly fileSessionId: string; + /** ISO stamp for this scan (lastSeenAt + when nothing else is available). */ + readonly nowIso: string; + /** IANA zone for day/hour bucketing (machine-local in prod, "UTC" in tests). */ + readonly timeZone: string; +} + +/** Build a present (on-disk) StatsSession from a file's telemetry. */ +export const aggregateSession = (input: AggregateInput): StatsSession => { + const { telemetry, projectDir, fileSessionId, nowIso, timeZone } = input; + const cwd = telemetry.cwd ?? ""; + const apiResponses = telemetry.events.filter( + (event): event is Extract => + event.kind === "api_response", + ); + + // tokens + latency + let inputTokens = 0; + let outputTokens = 0; + let thinkingTokens = 0; + let cachedTokens = 0; + let latencySum = 0; + let maxLatencyMs = 0; + const modelCounts = new Map(); + for (const response of apiResponses) { + inputTokens += response.inputTokens; + outputTokens += response.outputTokens; + thinkingTokens += response.thinkingTokens; + cachedTokens += response.cachedTokens; + latencySum += response.durationMs; + if (response.durationMs > maxLatencyMs) maxLatencyMs = response.durationMs; + if (response.model !== undefined) { + modelCounts.set(response.model, (modelCounts.get(response.model) ?? 0) + 1); + } + } + + // tools + approvals + const toolCounts: Record = {}; + const toolFailures: Record = {}; + let autoAccepted = 0; + let rejected = 0; + for (const event of telemetry.events) { + if (event.kind !== "tool_call") continue; + toolCounts[event.functionName] = (toolCounts[event.functionName] ?? 0) + 1; + if (!event.success) { + toolFailures[event.functionName] = (toolFailures[event.functionName] ?? 0) + 1; + } + if (event.decision === "auto_accept") autoAccepted += 1; + if (event.decision === "reject") rejected += 1; + } + + // errors + const errorTypes: Record = {}; + for (const event of telemetry.events) { + if (event.kind !== "api_error") continue; + errorTypes[event.errorType] = (errorTypes[event.errorType] ?? 0) + 1; + } + + // time-grain buckets: a day entry marks activity (any event); api_response events + // add their tokens/calls to that day and to the weekday+hour heatmap slot. + const tokensByDay: Record = {}; + const tokensByWeekdayHour: Record = {}; + const ensureDay = (day: string): MutableDayBucket => + (tokensByDay[day] ??= { input: 0, output: 0, thinking: 0, cached: 0, apiCalls: 0 }); + for (const event of telemetry.events) { + const day = dayOf(event.timestamp, timeZone); + if (day === "") continue; // unparseable timestamp — can't attribute to a day + const dayBucket = ensureDay(day); + if (event.kind !== "api_response") continue; + dayBucket.input += event.inputTokens; + dayBucket.output += event.outputTokens; + dayBucket.thinking += event.thinkingTokens; + dayBucket.cached += event.cachedTokens; + dayBucket.apiCalls += 1; + const visibleTokens = event.inputTokens + event.outputTokens + event.thinkingTokens; + const slot = `${weekdayOfDayKey(day)}:${hourOf(event.timestamp, timeZone)}`; + tokensByWeekdayHour[slot] = (tokensByWeekdayHour[slot] ?? 0) + visibleTokens; + } + + // time span + const timestamps = telemetry.events.map((event) => event.timestamp).toSorted(); + const startedAt = timestamps[0] ?? nowIso; + const endedAt = timestamps[timestamps.length - 1] ?? startedAt; + const durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(startedAt)); + + const category = classifyCategory(telemetry); + const isBackground = category !== "dialog"; + + return { + sessionId: telemetry.sessionId ?? fileSessionId, + projectId: projectDir, + projectLabel: projectLabelFor(cwd), + projectPath: cwd, + projectKind: isTempCwd(cwd) ? "temp" : "real", + branch: telemetry.branch ?? "", + model: dominant(modelCounts, ""), + startedAt, + durationMs, + turns: countTurns(telemetry.events), + category, + isBackground, + apiCalls: apiResponses.length, + tokens: { input: inputTokens, output: outputTokens, thinking: thinkingTokens, cached: cachedTokens }, + avgLatencyMs: apiResponses.length > 0 ? latencySum / apiResponses.length : 0, + maxLatencyMs, + toolCounts, + toolFailures, + errorTypes, + autoAccepted, + rejected, + tokensByDay, + tokensByWeekdayHour, + present: true, + lastSeenAt: IsoDateTime.make(nowIso), + }; +}; diff --git a/apps/server/src/ru-fork/stats/paths.ts b/apps/server/src/ru-fork/stats/paths.ts new file mode 100644 index 00000000000..6417eae62c0 --- /dev/null +++ b/apps/server/src/ru-fork/stats/paths.ts @@ -0,0 +1,40 @@ +// @effect-diagnostics nodeBuiltinImport:off +// ru-fork: GLOBAL projects-root resolver (all sessions, across every project) for the +// stats scan. The runtime base-dir priority lives in common/cliRuntimeRoots.ts (shared +// with the advanced-chat transcript reader), so a global scan and a single-thread read +// always agree on the base. A global scan has no per-thread cwd, so it omits it — the +// runtimeOutputDir setting tier is intentionally not wired here (Scope A). +import * as path from "node:path"; + +import { CHATS_DIRNAME, PROJECTS_DIRNAME, resolveRuntimeBaseDir } from "../common/cliRuntimeRoots.ts"; + +export interface StatsBaseInput { + readonly env: NodeJS.ProcessEnv; + /** ServerConfig.cliConfigDir = {home}/$CLI_DIR = qwen's default runtime base. */ + readonly cliConfigDir: string; +} + +/** `/projects` — the root that holds every project's chat directory. */ +export const resolveProjectsRoot = (input: StatsBaseInput): string => + path.join( + resolveRuntimeBaseDir({ env: input.env, cliConfigDir: input.cliConfigDir }), + PROJECTS_DIRNAME, + ); + +/** `//chats`. */ +export const chatsDirFor = (projectsRoot: string, projectDir: string): string => + path.join(projectsRoot, projectDir, CHATS_DIRNAME); + +/** Classify a real cwd path as a throwaway temp/sandbox dir. */ +export const isTempCwd = (cwd: string): boolean => + cwd.startsWith("/var/folders/") || + cwd.startsWith("/private/var/folders/") || + cwd.startsWith("/tmp/") || + cwd.startsWith("/private/tmp/"); + +/** Human label = last non-empty path segment of the real cwd. */ +export const projectLabelFor = (cwd: string): string => { + const segments = cwd.split(/[/\\]+/).filter(Boolean); + const lastSegment = segments[segments.length - 1]; + return lastSegment ?? cwd; +}; diff --git a/apps/server/src/ru-fork/stats/serviceSignatures.ts b/apps/server/src/ru-fork/stats/serviceSignatures.ts new file mode 100644 index 00000000000..d570aed0c79 --- /dev/null +++ b/apps/server/src/ru-fork/stats/serviceSignatures.ts @@ -0,0 +1,34 @@ +// ru-fork: our own one-shot text-generation calls (CliTextGeneration / +// TextGenerationPrompts) reach qwen as bare-hash sessions, indistinguishable from a +// real `qwen -p` except by the instruction we sent. We match the session's first user +// message against those instructions to categorize it. +// +// `instruction` is imported from the SINGLE source (textGeneration/instructions.ts), so +// it can't drift. `marker` is a distinctive substring of it — matched against the +// transcript (more robust than the full string to historical wording variants). The +// drift guard in serviceSignatures.test.ts asserts every marker IS a substring of its +// instruction, so rewording a prompt past its marker fails a test instead of silently +// misclassifying. +import type { StatsCategory } from "@t3tools/contracts"; + +import { + BRANCH_NAME_INSTRUCTION, + COMMIT_MESSAGE_INSTRUCTION, + PR_CONTENT_INSTRUCTION, + THREAD_TITLE_INSTRUCTION, +} from "../../textGeneration/instructions.ts"; + +export interface ServiceSignature { + readonly category: StatsCategory; + /** The full instruction our server sends (single source of truth). */ + readonly instruction: string; + /** Distinctive substring of `instruction`, matched against the transcript content. */ + readonly marker: string; +} + +export const SERVICE_SIGNATURES: readonly ServiceSignature[] = [ + { category: "title", instruction: THREAD_TITLE_INSTRUCTION, marker: "titles for coding conversations" }, + { category: "branch", instruction: BRANCH_NAME_INSTRUCTION, marker: "concise git branch name" }, + { category: "commit", instruction: COMMIT_MESSAGE_INSTRUCTION, marker: "git commit messages" }, + { category: "pr", instruction: PR_CONTENT_INSTRUCTION, marker: "pull request content" }, +]; diff --git a/apps/server/src/ru-fork/stats/telemetry.ts b/apps/server/src/ru-fork/stats/telemetry.ts new file mode 100644 index 00000000000..562e51773d1 --- /dev/null +++ b/apps/server/src/ru-fork/stats/telemetry.ts @@ -0,0 +1,149 @@ +// ru-fork: pure extractor for qwen's ui_telemetry events from a JSONL transcript. +// No I/O, no throws — malformed lines are skipped (one bad line never poisons a +// file). Reads systemPayload.uiEvent (the api_response / tool_call / api_error +// shapes the dashboard needs) plus the base cwd/gitBranch. Structural reads only, +// the same isObject/asString/asNumber style as qwen-transcript/parse.ts. + +type UnknownObject = Record; + +const isObject = (value: unknown): value is UnknownObject => + typeof value === "object" && value !== null && !Array.isArray(value); +const asString = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; +const asNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; +const asBoolean = (value: unknown): boolean | undefined => + typeof value === "boolean" ? value : undefined; + +export interface ApiResponseEvent { + readonly kind: "api_response"; + readonly timestamp: string; + readonly model: string | undefined; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cachedTokens: number; + readonly thinkingTokens: number; + readonly durationMs: number; + readonly promptId: string | undefined; +} +export interface ToolCallEvent { + readonly kind: "tool_call"; + readonly timestamp: string; + readonly functionName: string; + readonly success: boolean; + readonly decision: "auto_accept" | "reject" | undefined; +} +export interface ApiErrorEvent { + readonly kind: "api_error"; + readonly timestamp: string; + readonly errorType: string; +} +export type TelemetryEvent = ApiResponseEvent | ToolCallEvent | ApiErrorEvent; + +export interface FileTelemetry { + readonly events: ReadonlyArray; + /** First non-empty cwd seen (sessions are single-cwd). */ + readonly cwd: string | undefined; + /** Last non-empty gitBranch seen (the session's effective branch). */ + readonly branch: string | undefined; + /** First non-empty sessionId seen. */ + readonly sessionId: string | undefined; + /** Text of the first `user` record — used to recognize our service prompts. */ + readonly firstUserText: string | undefined; +} + +/** Pull the plain text out of a `user` record's `message` (string, or {parts:[{text}]}). */ +const userMessageText = (message: unknown): string | undefined => { + if (typeof message === "string") return message; + if (!isObject(message)) return undefined; + const parts = message["parts"]; + if (!Array.isArray(parts)) return asString(message["text"]); + const texts: string[] = []; + for (const part of parts) { + if (isObject(part)) { + const text = asString(part["text"]); + if (text !== undefined) texts.push(text); + } + } + return texts.length > 0 ? texts.join("\n") : undefined; +}; + +const decisionOf = (value: unknown): "auto_accept" | "reject" | undefined => + value === "auto_accept" || value === "reject" ? value : undefined; + +const extractUiEvent = (uiEvent: UnknownObject): TelemetryEvent | null => { + const name = asString(uiEvent["event.name"]); + const timestamp = asString(uiEvent["event.timestamp"]); + if (timestamp === undefined) return null; + if (name?.endsWith(".api_response")) { + return { + kind: "api_response", + timestamp, + model: asString(uiEvent["model"]), + inputTokens: asNumber(uiEvent["input_token_count"]) ?? 0, + outputTokens: asNumber(uiEvent["output_token_count"]) ?? 0, + cachedTokens: asNumber(uiEvent["cached_content_token_count"]) ?? 0, + thinkingTokens: asNumber(uiEvent["thoughts_token_count"]) ?? 0, + durationMs: asNumber(uiEvent["duration_ms"]) ?? 0, + promptId: asString(uiEvent["prompt_id"]), + }; + } + if (name?.endsWith(".tool_call")) { + const functionName = asString(uiEvent["function_name"]); + if (functionName === undefined) return null; + return { + kind: "tool_call", + timestamp, + functionName, + success: asBoolean(uiEvent["success"]) ?? true, + decision: decisionOf(uiEvent["decision"]), + }; + } + if (name?.endsWith(".api_error")) { + return { + kind: "api_error", + timestamp, + errorType: asString(uiEvent["error_type"]) ?? "UnknownError", + }; + } + return null; +}; + +/** JSONL text → telemetry events + the file's dimensions. */ +export const extractFileTelemetry = (text: string): FileTelemetry => { + const events: TelemetryEvent[] = []; + let cwd: string | undefined; + let branch: string | undefined; + let sessionId: string | undefined; + let firstUserText: string | undefined; + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + if (!isObject(parsed)) continue; + + if (cwd === undefined) cwd = asString(parsed["cwd"]); + const branchValue = asString(parsed["gitBranch"]); + if (branchValue !== undefined) branch = branchValue; + if (sessionId === undefined) sessionId = asString(parsed["sessionId"]); + if (firstUserText === undefined && parsed["type"] === "user") { + firstUserText = userMessageText(parsed["message"]); + } + + if (parsed["type"] !== "system" || parsed["subtype"] !== "ui_telemetry") continue; + const payload = parsed["systemPayload"]; + if (!isObject(payload)) continue; + const uiEvent = payload["uiEvent"]; + if (!isObject(uiEvent)) continue; + const event = extractUiEvent(uiEvent); + if (event !== null) events.push(event); + } + + return { events, cwd, branch, sessionId, firstUserText }; +}; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 67d91c116e2..6b071095e23 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -85,6 +85,9 @@ import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { McpRuntimeServicesLive } from "./ru-fork/mcp/McpLayers.ts"; +// ru-fork: stats (analytics) — DB-backed scanner; registered next to MCP so it +// shares the runtime SqlClient/FileSystem/Path/ServerConfig graph. +import { StatsLive } from "./ru-fork/stats/StatsLayers.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -221,6 +224,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // shared with the reactor + startup). Its repo/engine/settings requirements are // satisfied by the orchestration + settings layers provided below. Layer.provideMerge(McpRuntimeServicesLive), + // ru-fork: stats (analytics) scanner — shares the runtime graph below. + Layer.provideMerge(StatsLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), diff --git a/apps/server/src/textGeneration/CliTextGeneration.ts b/apps/server/src/textGeneration/CliTextGeneration.ts index 73c864d3259..2bc26b00efd 100644 --- a/apps/server/src/textGeneration/CliTextGeneration.ts +++ b/apps/server/src/textGeneration/CliTextGeneration.ts @@ -31,6 +31,7 @@ import { CLI_TEXT_GENERATION_TIMEOUT_MS } from "../timeouts.ts"; import { haltOnExit } from "../ru-fork/spawn/haltOnExit.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { type TextGenerationShape } from "./TextGeneration.ts"; +import { BRANCH_NAME_INSTRUCTION, THREAD_TITLE_INSTRUCTION } from "./instructions.ts"; import { buildCommitMessagePrompt, buildPrContentPrompt, @@ -322,7 +323,7 @@ export const makeCliTextGeneration = Effect.fn("makeCliTextGeneration")(function operation: "generateBranchName", cwd: input.cwd, prompt: buildCliSingleStringPrompt({ - instruction: "You generate concise git branch name fragments.", + instruction: BRANCH_NAME_INSTRUCTION, responseShape: "Reply with the branch name only — 2 to 6 plain words separated by hyphens. No JSON. No quotes. No preamble.", message: input.message, @@ -339,8 +340,7 @@ export const makeCliTextGeneration = Effect.fn("makeCliTextGeneration")(function operation: "generateThreadTitle", cwd: input.cwd, prompt: buildCliSingleStringPrompt({ - instruction: - "You write concise titles for coding conversations. Reply in Russian (Русский язык). Technical identifiers (file names, symbols) may stay in English.", + instruction: THREAD_TITLE_INSTRUCTION, responseShape: "Reply with the title only — 3 to 8 words in Russian. No quotes. No preamble. No JSON. No trailing punctuation.", message: input.message, diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 04bc9a0c929..04ba3f10f74 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import type { ChatAttachment } from "@t3tools/contracts"; +import { COMMIT_MESSAGE_INSTRUCTION, PR_CONTENT_INSTRUCTION } from "./instructions.ts"; import { limitSection } from "./TextGenerationUtils.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; @@ -44,7 +45,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { const wantsBranch = input.includeBranch; const prompt = [ - "You write concise git commit messages.", + COMMIT_MESSAGE_INSTRUCTION, wantsBranch ? "Return a JSON object with keys: subject, body, branch." : "Return a JSON object with keys: subject, body.", @@ -99,7 +100,7 @@ export interface PrContentPromptInput { export function buildPrContentPrompt(input: PrContentPromptInput) { const prompt = [ - "You write GitHub pull request content.", + PR_CONTENT_INSTRUCTION, "Return a JSON object with keys: title, body.", "Rules:", "- title MUST be in Russian (Русский язык), concise and specific, no trailing punctuation", diff --git a/apps/server/src/textGeneration/instructions.ts b/apps/server/src/textGeneration/instructions.ts new file mode 100644 index 00000000000..f7cb28b39fa --- /dev/null +++ b/apps/server/src/textGeneration/instructions.ts @@ -0,0 +1,14 @@ +// ru-fork: the instruction lines our text-generation calls send to the CLI. Kept here +// as a single source so the Stats feature can recognize these one-shot calls in qwen's +// transcripts (apps/server/src/ru-fork/stats/serviceSignatures.ts) without the two +// places drifting. A pure leaf module (plain strings, no deps) so importing it from the +// pure stats parser pulls nothing heavy. + +export const THREAD_TITLE_INSTRUCTION = + "You write concise titles for coding conversations. Reply in Russian (Русский язык). Technical identifiers (file names, symbols) may stay in English."; + +export const BRANCH_NAME_INSTRUCTION = "You generate concise git branch name fragments."; + +export const COMMIT_MESSAGE_INSTRUCTION = "You write concise git commit messages."; + +export const PR_CONTENT_INSTRUCTION = "You write GitHub pull request content."; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 51951dbe06b..a6ac853036b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -31,6 +31,8 @@ import { ThreadId, type TerminalEvent, TRANSCRIPT_WS_METHOD, + STATS_GET_SNAPSHOT_METHOD, + STATS_REFRESH_METHOD, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -47,6 +49,7 @@ import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { McpProjectionQuery } from "./ru-fork/mcp/McpProjectionQuery.ts"; +import { StatsScanner } from "./ru-fork/stats/StatsScanner.ts"; import { McpRuntime } from "./ru-fork/mcp/McpRuntime.ts"; import { McpSupervisor } from "./ru-fork/mcp/McpSupervisor.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const mcpProjectionQuery = yield* McpProjectionQuery; const mcpRuntime = yield* McpRuntime; const mcpSupervisor = yield* McpSupervisor; + // ru-fork: stats (analytics) — incremental scanner over the projects root. + const statsScanner = yield* StatsScanner; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); @@ -981,6 +986,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.mcpGetSnapshot, mcpProjectionQuery.getSnapshot(projectId), { "rpc.aggregate": "mcp", }), + // ru-fork: stats — getSnapshot = pure DB read (instant); refresh = scan disk, + // re-parse changed files, save, return (open + ⟳ button). + [STATS_GET_SNAPSHOT_METHOD]: (_input) => + observeRpcEffect(STATS_GET_SNAPSHOT_METHOD, statsScanner.getSnapshot(), { + "rpc.aggregate": "stats", + }), + [STATS_REFRESH_METHOD]: (_input) => + observeRpcEffect(STATS_REFRESH_METHOD, statsScanner.refresh(), { + "rpc.aggregate": "stats", + }), [WS_METHODS.subscribeMcpProjection]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeMcpProjection, diff --git a/apps/server/tests/persistence/Layers/StatsFileCache.test.ts b/apps/server/tests/persistence/Layers/StatsFileCache.test.ts new file mode 100644 index 00000000000..eb26f085f47 --- /dev/null +++ b/apps/server/tests/persistence/Layers/StatsFileCache.test.ts @@ -0,0 +1,144 @@ +import { IsoDateTime, type StatsSession } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { StatsFileCacheRepository } from "../../../src/persistence/Services/StatsFileCache.ts"; +import type { StatsFileCacheRow } from "../../../src/persistence/Services/StatsFileCache.ts"; +import { StatsFileCacheRepositoryLive } from "../../../src/persistence/Layers/ProjectionStatsFileCache.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; + +// A FRESH in-memory DB per test (provided inside each it.effect, not via the +// memoized it.layer) — listAll reads the whole table, so tests must not share rows. +const withRepo = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(StatsFileCacheRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))), + ); + +const session: StatsSession = { + sessionId: "sess-1", + projectId: "-Users-u-app", + projectLabel: "app", + projectPath: "/Users/u/app", + projectKind: "real", + branch: "main", + model: "qwen/qwen3.6-35b-a3b", + startedAt: "2026-06-10T10:00:00.000Z", + durationMs: 60000, + turns: 3, + category: "dialog", + isBackground: false, + apiCalls: 5, + tokens: { input: 5000, output: 200, thinking: 10, cached: 0 }, + avgLatencyMs: 8000, + maxLatencyMs: 20000, + toolCounts: { write_file: 2 }, + toolFailures: { write_file: 1 }, + errorTypes: {}, + autoAccepted: 1, + rejected: 0, + tokensByDay: { "2026-06-10": { input: 5000, output: 200, thinking: 10, cached: 0, apiCalls: 5 } }, + tokensByWeekdayHour: { "2:10": 5210 }, + present: true, + lastSeenAt: IsoDateTime.make("2026-06-17T12:00:00.000Z"), +}; + +const row: StatsFileCacheRow = { + filePath: "/root/projects/-Users-u-app/chats/sess-1.jsonl", + mtimeMs: 1_780_000_000_000, + sizeBytes: 4096, + present: true, + lastSeenAt: "2026-06-17T12:00:00.000Z", + session, +}; + +it.effect("upserts then lists back the decoded row (session_json round-trips)", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + yield* repo.upsert(row); + const rows = yield* repo.listAll(); + assert.equal(rows.length, 1); + const [found] = rows; + assert.equal(found.filePath, row.filePath); + assert.equal(found.present, true); + assert.equal(found.session.apiCalls, 5); + assert.deepEqual(found.session.toolCounts, { write_file: 2 }); + assert.equal(found.mtimeMs, row.mtimeMs); + }), + ), +); + +it.effect("upsert overwrites the row for the same file_path", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + yield* repo.upsert(row); + yield* repo.upsert({ ...row, sizeBytes: 8192, session: { ...session, apiCalls: 9 } }); + const rows = yield* repo.listAll(); + assert.equal(rows.length, 1); + assert.equal(rows[0].sizeBytes, 8192); + assert.equal(rows[0].session.apiCalls, 9); + }), + ), +); + +it.effect("markAbsent flips present→false and keeps the row (retain-after-delete)", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + yield* repo.upsert(row); + yield* repo.markAbsent({ filePaths: [row.filePath], lastSeenAt: "2026-06-18T00:00:00.000Z" }); + const rows = yield* repo.listAll(); + assert.equal(rows.length, 1); + assert.equal(rows[0].present, false); + assert.equal(rows[0].lastSeenAt, "2026-06-18T00:00:00.000Z"); + }), + ), +); + +it.effect("markAbsent with an empty list is a no-op", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + yield* repo.upsert(row); + yield* repo.markAbsent({ filePaths: [], lastSeenAt: "2026-06-18T00:00:00.000Z" }); + const rows = yield* repo.listAll(); + assert.equal(rows[0].present, true); + }), + ), +); + +it.effect("listAll returns empty on a fresh DB", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + assert.equal((yield* repo.listAll()).length, 0); + }), + ), +); + +it.effect("removeByPaths hard-deletes the named rows (ghost purge)", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + yield* repo.upsert(row); + yield* repo.upsert({ ...row, filePath: "/root/projects/x/chats/ghost.jsonl" }); + yield* repo.removeByPaths(["/root/projects/x/chats/ghost.jsonl"]); + const rows = yield* repo.listAll(); + assert.equal(rows.length, 1); + assert.equal(rows[0].filePath, row.filePath); + }), + ), +); + +it.effect("removeByPaths with an empty list is a no-op", () => + withRepo( + Effect.gen(function* () { + const repo = yield* StatsFileCacheRepository; + yield* repo.upsert(row); + yield* repo.removeByPaths([]); + assert.equal((yield* repo.listAll()).length, 1); + }), + ), +); diff --git a/apps/server/tests/ru-fork/common/cliRuntimeRoots.test.ts b/apps/server/tests/ru-fork/common/cliRuntimeRoots.test.ts new file mode 100644 index 00000000000..10b3d235724 --- /dev/null +++ b/apps/server/tests/ru-fork/common/cliRuntimeRoots.test.ts @@ -0,0 +1,116 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as os from "node:os"; +import * as path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + type RuntimeBaseInput, + expandRuntimeBaseDir, + resolveRuntimeBaseDir, + sanitizeCwd, +} from "../../../src/ru-fork/common/cliRuntimeRoots.ts"; + +const base = (overrides: Partial): RuntimeBaseInput => ({ + env: {}, + cliConfigDir: "/home/user/.qwen", + cwd: "/work/project", + runtimeOutputDirSetting: undefined, + ...overrides, +}); + +describe("sanitizeCwd", () => { + it("replaces every non-alphanumeric character with a hyphen", () => { + expect(sanitizeCwd("/Users/x/My Project.v2")).toBe("-Users-x-My-Project-v2"); + }); + + it("preserves alphanumerics", () => { + expect(sanitizeCwd("abc123")).toBe("abc123"); + }); + + it("lowercases on win32", () => { + expect(sanitizeCwd("C:\\Foo", "win32")).toBe("c--foo"); + }); +}); + +describe("expandRuntimeBaseDir", () => { + it("expands a bare tilde to the home directory", () => { + expect(expandRuntimeBaseDir("~", "/work")).toBe(os.homedir()); + }); + + it("expands ~/segment relative to home", () => { + expect(expandRuntimeBaseDir("~/foo/bar", "/work")).toBe(path.join(os.homedir(), "foo", "bar")); + }); + + it("expands ~\\segment (backslash) relative to home", () => { + expect(expandRuntimeBaseDir("~\\foo", "/work")).toBe(path.join(os.homedir(), "foo")); + }); + + it("resolves a relative path against cwd", () => { + expect(expandRuntimeBaseDir("out/dir", "/work")).toBe("/work/out/dir"); + }); + + it("resolves a relative path against home when no cwd is given (global scan)", () => { + expect(expandRuntimeBaseDir("out/dir")).toBe(path.join(os.homedir(), "out", "dir")); + }); + + it("leaves an absolute path unchanged", () => { + expect(expandRuntimeBaseDir("/abs/dir", "/work")).toBe("/abs/dir"); + }); +}); + +describe("resolveRuntimeBaseDir", () => { + it("prefers an absolute QWEN_RUNTIME_DIR env var", () => { + expect(resolveRuntimeBaseDir(base({ env: { QWEN_RUNTIME_DIR: "/custom/runtime" } }))).toBe( + "/custom/runtime", + ); + }); + + it("expands a tilde QWEN_RUNTIME_DIR", () => { + expect(resolveRuntimeBaseDir(base({ env: { QWEN_RUNTIME_DIR: "~/rt" } }))).toBe( + path.join(os.homedir(), "rt"), + ); + }); + + it("resolves a relative QWEN_RUNTIME_DIR against cwd", () => { + expect(resolveRuntimeBaseDir(base({ env: { QWEN_RUNTIME_DIR: "rt" }, cwd: "/c" }))).toBe("/c/rt"); + }); + + it("ignores a whitespace-only QWEN_RUNTIME_DIR and falls through", () => { + expect(resolveRuntimeBaseDir(base({ env: { QWEN_RUNTIME_DIR: " " } }))).toBe("/home/user/.qwen"); + }); + + it("uses the runtimeOutputDir setting when no env var", () => { + expect(resolveRuntimeBaseDir(base({ runtimeOutputDirSetting: "/setting/dir" }))).toBe( + "/setting/dir", + ); + }); + + it("expands a relative runtimeOutputDir setting against cwd", () => { + expect(resolveRuntimeBaseDir(base({ runtimeOutputDirSetting: "rel", cwd: "/c" }))).toBe("/c/rel"); + }); + + it("falls back to cliConfigDir when nothing is set", () => { + expect(resolveRuntimeBaseDir(base({}))).toBe("/home/user/.qwen"); + }); +}); + +// The whole point of sharing this resolver: a single-thread transcript read (cwd known) +// and a global stats scan (no cwd) must resolve the SAME base for the same env+config, so +// the two readers can never silently diverge again. +describe("transcript ↔ stats base agreement", () => { + it("resolves the same base whether or not a cwd is supplied (no override set)", () => { + const withCwd = resolveRuntimeBaseDir({ env: {}, cliConfigDir: "/home/user/.qwen", cwd: "/work" }); + const withoutCwd = resolveRuntimeBaseDir({ env: {}, cliConfigDir: "/home/user/.qwen" }); + expect(withCwd).toBe(withoutCwd); + expect(withoutCwd).toBe("/home/user/.qwen"); + }); + + it("honors an absolute QWEN_RUNTIME_DIR identically with and without cwd", () => { + const env = { QWEN_RUNTIME_DIR: "/custom/runtime" }; + const withCwd = resolveRuntimeBaseDir({ env, cliConfigDir: "/home/user/.qwen", cwd: "/work" }); + const withoutCwd = resolveRuntimeBaseDir({ env, cliConfigDir: "/home/user/.qwen" }); + expect(withCwd).toBe(withoutCwd); + expect(withoutCwd).toBe("/custom/runtime"); + }); +}); diff --git a/apps/server/tests/ru-fork/qwen-transcript/paths.test.ts b/apps/server/tests/ru-fork/qwen-transcript/paths.test.ts index 3c1d8bef550..681f23a7572 100644 --- a/apps/server/tests/ru-fork/qwen-transcript/paths.test.ts +++ b/apps/server/tests/ru-fork/qwen-transcript/paths.test.ts @@ -1,18 +1,15 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as os from "node:os"; -import * as path from "node:path"; - import { describe, expect, it } from "vitest"; import { - expandRuntimeBaseDir, + type TranscriptBaseInput, resolveChatsDir, - resolveTranscriptBaseDir, resolveTranscriptFilePath, - sanitizeCwd, - type TranscriptBaseInput, } from "../../../src/ru-fork/qwen-transcript/paths.ts"; +// The runtime base-dir priority (env / runtimeOutputDir / cliConfigDir) is shared and +// tested in common/cliRuntimeRoots.test.ts; here we only cover the transcript-specific +// path shaping `/projects//chats/.jsonl`. const base = (overrides: Partial): TranscriptBaseInput => ({ env: {}, cliConfigDir: "/home/user/.qwen", @@ -21,84 +18,6 @@ const base = (overrides: Partial): TranscriptBaseInput => ( ...overrides, }); -describe("sanitizeCwd", () => { - it("replaces every non-alphanumeric character with a hyphen", () => { - expect(sanitizeCwd("/Users/x/My Project.v2")).toBe("-Users-x-My-Project-v2"); - }); - - it("preserves alphanumerics", () => { - expect(sanitizeCwd("abc123")).toBe("abc123"); - }); - - it("lowercases on win32", () => { - expect(sanitizeCwd("C:\\Foo", "win32")).toBe("c--foo"); - }); -}); - -describe("expandRuntimeBaseDir", () => { - it("expands a bare tilde to the home directory", () => { - expect(expandRuntimeBaseDir("~", "/work")).toBe(os.homedir()); - }); - - it("expands ~/segment relative to home", () => { - expect(expandRuntimeBaseDir("~/foo/bar", "/work")).toBe(path.join(os.homedir(), "foo", "bar")); - }); - - it("expands ~\\segment (backslash) relative to home", () => { - expect(expandRuntimeBaseDir("~\\foo", "/work")).toBe(path.join(os.homedir(), "foo")); - }); - - it("resolves a relative path against cwd", () => { - expect(expandRuntimeBaseDir("out/dir", "/work")).toBe("/work/out/dir"); - }); - - it("leaves an absolute path unchanged", () => { - expect(expandRuntimeBaseDir("/abs/dir", "/work")).toBe("/abs/dir"); - }); -}); - -describe("resolveTranscriptBaseDir", () => { - it("prefers an absolute QWEN_RUNTIME_DIR env var", () => { - expect(resolveTranscriptBaseDir(base({ env: { QWEN_RUNTIME_DIR: "/custom/runtime" } }))).toBe( - "/custom/runtime", - ); - }); - - it("expands a tilde QWEN_RUNTIME_DIR", () => { - expect(resolveTranscriptBaseDir(base({ env: { QWEN_RUNTIME_DIR: "~/rt" } }))).toBe( - path.join(os.homedir(), "rt"), - ); - }); - - it("resolves a relative QWEN_RUNTIME_DIR against cwd", () => { - expect( - resolveTranscriptBaseDir(base({ env: { QWEN_RUNTIME_DIR: "rt" }, cwd: "/c" })), - ).toBe("/c/rt"); - }); - - it("ignores a whitespace-only QWEN_RUNTIME_DIR and falls through", () => { - expect(resolveTranscriptBaseDir(base({ env: { QWEN_RUNTIME_DIR: " " } }))).toBe( - "/home/user/.qwen", - ); - }); - - it("uses the runtimeOutputDir setting when no env var", () => { - expect(resolveTranscriptBaseDir(base({ runtimeOutputDirSetting: "/setting/dir" }))).toBe( - "/setting/dir", - ); - }); - - it("expands a relative runtimeOutputDir setting against cwd", () => { - expect( - resolveTranscriptBaseDir(base({ runtimeOutputDirSetting: "rel", cwd: "/c" })), - ).toBe("/c/rel"); - }); - - it("falls back to cliConfigDir when nothing is set", () => { - expect(resolveTranscriptBaseDir(base({}))).toBe("/home/user/.qwen"); - }); -}); - describe("resolveChatsDir / resolveTranscriptFilePath", () => { it("assembles /projects//chats with no tmp segment or hash", () => { const chatsDir = resolveChatsDir(base({ cwd: "/work/project" })); diff --git a/apps/server/tests/ru-fork/stats/aggregate.test.ts b/apps/server/tests/ru-fork/stats/aggregate.test.ts new file mode 100644 index 00000000000..c28d2b5d426 --- /dev/null +++ b/apps/server/tests/ru-fork/stats/aggregate.test.ts @@ -0,0 +1,192 @@ +import { assert, it } from "@effect/vitest"; + +import { aggregateSession } from "../../../src/ru-fork/stats/aggregate.ts"; +import type { FileTelemetry, TelemetryEvent } from "../../../src/ru-fork/stats/telemetry.ts"; + +const NOW = "2026-06-17T12:00:00.000Z"; + +const makeSession = (events: ReadonlyArray, overrides: Partial = {}) => + aggregateSession({ + telemetry: { + events, + cwd: "/Users/u/WORKSPACE/Projects/app", + branch: "main", + sessionId: "sess-1", + firstUserText: undefined, + ...overrides, + }, + projectDir: "-Users-u-WORKSPACE-Projects-app", + fileSessionId: "file-sess", + nowIso: NOW, + timeZone: "UTC", + }); + +const apiResponse = (overrides: Partial> = {}): TelemetryEvent => ({ + kind: "api_response", + timestamp: "2026-06-10T10:00:00.000Z", + model: "qwen/qwen3.6-35b-a3b", + inputTokens: 1000, + outputTokens: 50, + thinkingTokens: 5, + cachedTokens: 0, + durationMs: 8000, + promptId: "sess-1########1", + ...overrides, +}); + +it("sums tokens across api_response events", () => { + const session = makeSession([apiResponse(), apiResponse({ inputTokens: 2000, outputTokens: 100 })]); + assert.equal(session.tokens.input, 3000); + assert.equal(session.tokens.output, 150); + assert.equal(session.tokens.thinking, 10); + assert.equal(session.tokens.cached, 0); + assert.equal(session.apiCalls, 2); +}); + +it("computes avg + max latency", () => { + const session = makeSession([apiResponse({ durationMs: 4000 }), apiResponse({ durationMs: 12000 })]); + assert.equal(session.avgLatencyMs, 8000); + assert.equal(session.maxLatencyMs, 12000); +}); + +it("counts distinct turns, excluding side-queries", () => { + const session = makeSession([ + apiResponse({ promptId: "sess-1########1" }), + apiResponse({ promptId: "sess-1########1" }), // same turn + apiResponse({ promptId: "sess-1########2" }), + apiResponse({ promptId: "side-query:auto-memory-recall" }), // excluded + ]); + assert.equal(session.turns, 2); +}); + +it("marks a fully side-query session as background", () => { + const session = makeSession([apiResponse({ promptId: "side-query:a" }), apiResponse({ promptId: "side-query:b" })]); + assert.equal(session.isBackground, true); + assert.equal(session.turns, 0); +}); + +it("a session with any real turn is not background", () => { + const session = makeSession([apiResponse({ promptId: "side-query:a" }), apiResponse({ promptId: "sess-1########1" })]); + assert.equal(session.isBackground, false); +}); + +it("aggregates tool calls, failures, and approval decisions", () => { + const session = makeSession([ + { kind: "tool_call", timestamp: "t", functionName: "write_file", success: true, decision: "auto_accept" }, + { kind: "tool_call", timestamp: "t", functionName: "write_file", success: false, decision: undefined }, + { kind: "tool_call", timestamp: "t", functionName: "run_shell_command", success: false, decision: "reject" }, + ]); + assert.deepEqual(session.toolCounts, { write_file: 2, run_shell_command: 1 }); + assert.deepEqual(session.toolFailures, { write_file: 1, run_shell_command: 1 }); + assert.equal(session.autoAccepted, 1); + assert.equal(session.rejected, 1); +}); + +it("aggregates error types", () => { + const session = makeSession([ + { kind: "api_error", timestamp: "t", errorType: "APIError" }, + { kind: "api_error", timestamp: "t", errorType: "APIError" }, + { kind: "api_error", timestamp: "t", errorType: "BadRequestError" }, + ]); + assert.deepEqual(session.errorTypes, { APIError: 2, BadRequestError: 1 }); +}); + +it("derives time span + duration from event timestamps", () => { + const session = makeSession([ + apiResponse({ timestamp: "2026-06-10T10:00:00.000Z" }), + apiResponse({ timestamp: "2026-06-10T10:05:00.000Z" }), + ]); + assert.equal(session.startedAt, "2026-06-10T10:00:00.000Z"); + assert.equal(session.durationMs, 300000); +}); + +it("buckets tokens by UTC day across multiple days", () => { + const session = makeSession([ + apiResponse({ timestamp: "2026-06-19T10:00:00.000Z", inputTokens: 1000, outputTokens: 10 }), + apiResponse({ timestamp: "2026-06-19T14:00:00.000Z", inputTokens: 2000, outputTokens: 20 }), + apiResponse({ timestamp: "2026-06-20T09:00:00.000Z", inputTokens: 500, outputTokens: 5 }), + ]); + assert.deepEqual(session.tokensByDay["2026-06-19"], { input: 3000, output: 30, thinking: 10, cached: 0, apiCalls: 2 }); + assert.deepEqual(session.tokensByDay["2026-06-20"], { input: 500, output: 5, thinking: 5, cached: 0, apiCalls: 1 }); +}); + +it("buckets visible tokens by weekday:hour (Mon=0, UTC)", () => { + // 2026-06-19 is a Friday (weekday 4); 10:00Z → slot "4:10". + const session = makeSession([apiResponse({ timestamp: "2026-06-19T10:00:00.000Z", inputTokens: 1000, outputTokens: 50, thinkingTokens: 5 })]); + assert.equal(session.tokensByWeekdayHour["4:10"], 1055); +}); + +it("records an activity day even for a token-less event (api_error)", () => { + const session = makeSession([{ kind: "api_error", timestamp: "2026-06-19T10:00:00.000Z", errorType: "APIError" }]); + assert.deepEqual(session.tokensByDay["2026-06-19"], { input: 0, output: 0, thinking: 0, cached: 0, apiCalls: 0 }); + assert.equal(Object.keys(session.tokensByWeekdayHour).length, 0); +}); + +it("picks the dominant model", () => { + const session = makeSession([ + apiResponse({ model: "qwen/a" }), + apiResponse({ model: "qwen/b" }), + apiResponse({ model: "qwen/b" }), + ]); + assert.equal(session.model, "qwen/b"); +}); + +it("categorizes a real ######## turn as dialog", () => { + const session = makeSession([apiResponse({ promptId: "sess-1########1" })]); + assert.equal(session.category, "dialog"); + assert.equal(session.isBackground, false); +}); + +it("categorizes side-query / compress / subagent by prompt_id", () => { + assert.equal(makeSession([apiResponse({ promptId: "side-query:auto-memory-recall" })]).category, "memory"); + assert.equal(makeSession([apiResponse({ promptId: "compress-3" })]).category, "compress"); + assert.equal(makeSession([apiResponse({ promptId: "uuid#Explore-abc#1" })]).category, "subagent"); +}); + +it("categorizes our service prompts by content (title), and marks them background", () => { + const session = makeSession([apiResponse({ promptId: "0a15340de2a" })], { + firstUserText: "You write concise titles for coding conversations. Reply in Russian.", + }); + assert.equal(session.category, "title"); + assert.equal(session.isBackground, true); + assert.equal(session.turns, 0); +}); + +it("falls back to 'service' for an unrecognized one-shot", () => { + const session = makeSession([apiResponse({ promptId: "deadbeef123" })], { firstUserText: "hi" }); + assert.equal(session.category, "service"); + assert.equal(session.isBackground, true); +}); + +it("classifies temp cwd as a sandbox project + labels by last segment", () => { + const session = makeSession([apiResponse()], { cwd: "/var/folders/41/T/acp-test-xyz" }); + assert.equal(session.projectKind, "temp"); + assert.equal(session.projectLabel, "acp-test-xyz"); +}); + +it("classifies a normal cwd as real + keeps project id from the dir", () => { + const session = makeSession([apiResponse()]); + assert.equal(session.projectKind, "real"); + assert.equal(session.projectLabel, "app"); + assert.equal(session.projectId, "-Users-u-WORKSPACE-Projects-app"); +}); + +it("falls back to fileSessionId + nowIso for an empty file", () => { + const session = aggregateSession({ + telemetry: { events: [], cwd: undefined, branch: undefined, sessionId: undefined, firstUserText: undefined }, + projectDir: "dir", + fileSessionId: "abc", + nowIso: NOW, + timeZone: "UTC", + }); + assert.equal(session.sessionId, "abc"); + assert.equal(session.startedAt, NOW); + assert.equal(session.apiCalls, 0); + assert.equal(session.avgLatencyMs, 0); + // No real ######## turn → not a dialog (empties are ghost-skipped in production anyway). + assert.equal(session.category, "service"); + assert.equal(session.isBackground, true); + assert.equal(session.present, true); + assert.equal(session.branch, ""); + assert.equal(session.model, ""); +}); diff --git a/apps/server/tests/ru-fork/stats/paths.test.ts b/apps/server/tests/ru-fork/stats/paths.test.ts new file mode 100644 index 00000000000..6987a393c08 --- /dev/null +++ b/apps/server/tests/ru-fork/stats/paths.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { isTempCwd, projectLabelFor, resolveProjectsRoot } from "../../../src/ru-fork/stats/paths.ts"; + +it("uses QWEN_RUNTIME_DIR when set", () => { + const root = resolveProjectsRoot({ env: { QWEN_RUNTIME_DIR: "/custom/runtime" }, cliConfigDir: "/ignored" }); + assert.equal(root, path.join("/custom/runtime", "projects")); +}); + +it("expands a tilde in QWEN_RUNTIME_DIR", () => { + const root = resolveProjectsRoot({ env: { QWEN_RUNTIME_DIR: "~/rt" }, cliConfigDir: "/ignored" }); + assert.equal(root, path.join(os.homedir(), "rt", "projects")); +}); + +it("falls back to cliConfigDir when no env override", () => { + const root = resolveProjectsRoot({ env: {}, cliConfigDir: "/home/u/.qwen" }); + assert.equal(root, path.join("/home/u/.qwen", "projects")); +}); + +it("ignores a blank QWEN_RUNTIME_DIR", () => { + const root = resolveProjectsRoot({ env: { QWEN_RUNTIME_DIR: " " }, cliConfigDir: "/home/u/.qwen" }); + assert.equal(root, path.join("/home/u/.qwen", "projects")); +}); + +it("detects temp/sandbox cwds", () => { + assert.isTrue(isTempCwd("/var/folders/41/T/acp-test")); + assert.isTrue(isTempCwd("/private/var/folders/x/T/y")); + assert.isTrue(isTempCwd("/tmp/scratch")); + assert.isFalse(isTempCwd("/Users/u/WORKSPACE/Projects/app")); +}); + +it("labels a cwd by its last path segment", () => { + assert.equal(projectLabelFor("/Users/u/WORKSPACE/Projects/app"), "app"); + assert.equal(projectLabelFor("/Users/u/WORKSPACE/Projects/app/"), "app"); +}); diff --git a/apps/server/tests/ru-fork/stats/serviceSignatures.test.ts b/apps/server/tests/ru-fork/stats/serviceSignatures.test.ts new file mode 100644 index 00000000000..fa3c546187f --- /dev/null +++ b/apps/server/tests/ru-fork/stats/serviceSignatures.test.ts @@ -0,0 +1,22 @@ +import { assert, it } from "@effect/vitest"; + +import { SERVICE_SIGNATURES } from "../../../src/ru-fork/stats/serviceSignatures.ts"; + +// Drift guard: each marker MUST be a substring of the instruction it stands for. If a +// text-generation prompt is reworded past its marker, this fails loudly here instead of +// silently misclassifying those sessions as "service". +it("every service marker is a substring of its instruction", () => { + for (const signature of SERVICE_SIGNATURES) { + assert.isTrue( + signature.instruction.includes(signature.marker), + `${signature.category}: marker "${signature.marker}" is not in its instruction`, + ); + } +}); + +it("covers exactly the four text-generation categories", () => { + assert.deepEqual( + SERVICE_SIGNATURES.map((signature) => signature.category).toSorted(), + ["branch", "commit", "pr", "title"], + ); +}); diff --git a/apps/server/tests/ru-fork/stats/statsScanner.test.ts b/apps/server/tests/ru-fork/stats/statsScanner.test.ts new file mode 100644 index 00000000000..b16dfe72a1a --- /dev/null +++ b/apps/server/tests/ru-fork/stats/statsScanner.test.ts @@ -0,0 +1,279 @@ +import { assert, it } from "@effect/vitest"; +import { beforeEach } from "vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerConfig } from "../../../src/config.ts"; +import { StatsScanner } from "../../../src/ru-fork/stats/StatsScanner.ts"; +import { StatsLive } from "../../../src/ru-fork/stats/StatsLayers.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +// Verified specifier (serverRuntimeStartup.test.ts:2, fastShutdownOverlaySweep.test.ts:13). +import * as NodeServices from "@effect/platform-node/NodeServices"; + +// One ui_telemetry api_response line. +const apiResponseLine = (tokens: number, promptIndex: number, cwd: string) => + JSON.stringify({ + type: "system", + subtype: "ui_telemetry", + cwd, + gitBranch: "main", + sessionId: "s", + timestamp: "2026-06-10T10:00:00.000Z", + systemPayload: { + uiEvent: { + "event.name": "qwen-code.api_response", + "event.timestamp": "2026-06-10T10:00:00.000Z", + model: "qwen/qwen3.6-35b-a3b", + input_token_count: tokens, + output_token_count: 10, + cached_content_token_count: 0, + thoughts_token_count: 0, + duration_ms: 5000, + prompt_id: `s########${promptIndex}`, + }, + }, + }); + +// Seed `//chats/.jsonl`. Returns its absolute path. +// projectsBase is the scoped temp dir the scanner is pointed at (QWEN_RUNTIME_DIR), +// so each test gets its own isolated, auto-cleaned projects tree. +const seedFile = (projectsBase: string, dirName: string, sessionId: string, text: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const chatsDir = path.join(projectsBase, "projects", dirName, "chats"); + yield* fileSystem.makeDirectory(chatsDir, { recursive: true }); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + yield* fileSystem.writeFileString(filePath, text); + return filePath; + }); + +const TEST_CWD = "/Users/u/WORKSPACE/Projects/app"; +const TEST_DIR = "-Users-u-WORKSPACE-Projects-app"; + +// Full graph for one test, parameterised by the runtime temp baseDir. provideMerge +// (not mergeAll) so StatsLive's SqlClient/FileSystem/Path/ServerConfig requirements +// are actually satisfied — and every output (StatsScanner, FileSystem, Path, +// ServerConfig) is exposed to the test body. +const harness = (baseDir: string) => + StatsLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge(NodeServices.layer), + ); + +// Create a scoped temp dir (outer NodeServices), point the scanner at it via +// QWEN_RUNTIME_DIR (so every test's projects tree is unique + auto-cleaned), then +// build + provide the harness for that baseDir to the body. ServerConfig depends on +// baseDir (a runtime value), so the harness is built inside the effect, not statically. +const runInTemp = ( + body: ( + projectsBase: string, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped(); + process.env.QWEN_RUNTIME_DIR = baseDir; + return yield* body(baseDir).pipe(Effect.provide(harness(baseDir))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +// The scanner reads process.env; clear any ambient/leftover override before each +// test (runInTemp then sets it to the test's own scoped dir). +beforeEach(() => { + delete process.env.QWEN_RUNTIME_DIR; +}); + +it.effect("refresh parses every file and computes real fields", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + yield* seedFile(projectsBase, TEST_DIR, "s", [apiResponseLine(1000, 1, TEST_CWD), apiResponseLine(2000, 2, TEST_CWD)].join("\n")); + const scanner = yield* StatsScanner; + const snapshot = yield* scanner.refresh(); + assert.equal(snapshot.scannedFiles, 1); + assert.equal(snapshot.parsedFiles, 1); + assert.equal(snapshot.sessions.length, 1); + const [session] = snapshot.sessions; + assert.equal(session.tokens.input, 3000); + assert.equal(session.apiCalls, 2); + assert.equal(session.turns, 2); + assert.equal(session.projectId, TEST_DIR); + assert.equal(session.projectKind, "real"); + assert.equal(session.present, true); + }), + ), +); + +it.effect("getSnapshot before any refresh is empty (pure read, never scans)", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + const snapshot = yield* scanner.getSnapshot(); + // The file exists on disk, but getSnapshot does not scan — DB is still empty. + assert.equal(snapshot.sessions.length, 0); + assert.equal(snapshot.scannedFiles, 0); + assert.equal(snapshot.parsedFiles, 0); + }), + ), +); + +it.effect("getSnapshot returns the stored rows after a refresh, without re-scanning", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + const filePath = yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + yield* scanner.refresh(); + const fileSystem = yield* FileSystem.FileSystem; + // Delete the file AFTER refresh; getSnapshot must NOT notice (it never scans). + yield* fileSystem.remove(filePath); + const snapshot = yield* scanner.getSnapshot(); + assert.equal(snapshot.sessions.length, 1); + assert.equal(snapshot.sessions[0].present, true); // still present — no scan ran + assert.equal(snapshot.scannedFiles, 0); + }), + ), +); + +it.effect("second refresh reuses unchanged files (parsedFiles = 0)", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + yield* scanner.refresh(); + const second = yield* scanner.refresh(); + assert.equal(second.parsedFiles, 0); + assert.equal(second.sessions.length, 1); + }), + ), +); + +it.effect("a changed file (new size) is re-parsed on refresh", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + const filePath = yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + yield* scanner.refresh(); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, [apiResponseLine(1000, 1, TEST_CWD), apiResponseLine(5000, 2, TEST_CWD)].join("\n")); + const second = yield* scanner.refresh(); + assert.equal(second.parsedFiles, 1); + assert.equal(second.sessions[0].tokens.input, 6000); + }), + ), +); + +it.effect("a new file in a new project dir is picked up on refresh", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + yield* scanner.refresh(); + yield* seedFile(projectsBase, "-tmp-other", "s2", apiResponseLine(7000, 1, "/tmp/other")); + const second = yield* scanner.refresh(); + assert.equal(second.sessions.length, 2); + assert.isTrue(second.sessions.some((session) => session.projectKind === "temp")); + }), + ), +); + +it.effect("a deleted file is retained with present=false after refresh (retain-after-delete)", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + const filePath = yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + yield* scanner.refresh(); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(filePath); + const second = yield* scanner.refresh(); + assert.equal(second.scannedFiles, 0); + assert.equal(second.sessions.length, 1); + assert.equal(second.sessions[0].present, false); + }), + ), +); + +it.effect("files with no api_response are skipped (ghost: empty, or only tool/error)", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + // A real session, plus two "ghosts" with zero successful responses. + yield* seedFile(projectsBase, TEST_DIR, "real", apiResponseLine(1000, 1, TEST_CWD)); + const emptyLine = JSON.stringify({ type: "user", cwd: "/tmp/ghost", sessionId: "g", timestamp: "2026-06-10T10:00:00.000Z" }); + yield* seedFile(projectsBase, "-tmp-empty", "empty", emptyLine); + const errorOnlyLine = JSON.stringify({ + type: "system", + subtype: "ui_telemetry", + cwd: "/tmp/err", + sessionId: "e", + timestamp: "2026-06-10T10:00:00.000Z", + systemPayload: { + uiEvent: { + "event.name": "qwen-code.api_error", + "event.timestamp": "2026-06-10T10:00:00.000Z", + error_type: "APIError", + }, + }, + }); + yield* seedFile(projectsBase, "-tmp-erroronly", "erroronly", errorOnlyLine); + const scanner = yield* StatsScanner; + const snapshot = yield* scanner.refresh(); + assert.equal(snapshot.scannedFiles, 3); // all three files seen on disk + assert.equal(snapshot.sessions.length, 1); // only the one with a response is a session + assert.equal(snapshot.sessions[0].projectId, TEST_DIR); + }), + ), +); + +it.effect("refresh with a missing projects root yields an empty snapshot (no throw)", () => + runInTemp(() => + Effect.gen(function* () { + const scanner = yield* StatsScanner; + const snapshot = yield* scanner.refresh(); + assert.equal(snapshot.sessions.length, 0); + assert.equal(snapshot.scannedFiles, 0); + }), + ), +); + +// Plant a row whose session_json is valid JSON but an OLDER StatsSession shape (missing +// today's required fields) — exactly what a persistent on-disk cache holds after the +// schema evolved. fromJsonString(StatsSession) rejects it, so a non-tolerant listAll +// fails the whole read and wedges both getSnapshot and refresh (the real-machine error). +const seedStaleCacheRow = (filePath: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO stats_file_cache (file_path, mtime_ms, size_bytes, present, last_seen_at, session_json) + VALUES (${filePath}, ${1}, ${1}, ${1}, ${"2026-06-10T10:00:00.000Z"}, ${'{"sessionId":"old"}'}) + `; + }); + +it.effect("getSnapshot skips a cached row whose session_json no longer matches the schema", () => + runInTemp(() => + Effect.gen(function* () { + yield* seedStaleCacheRow("/old/stale.jsonl"); + const scanner = yield* StatsScanner; + // Today this THROWS PersistenceSqlError (SchemaError) on the stale row; the fix + // should drop the undecodable row and return cleanly. + const snapshot = yield* scanner.getSnapshot(); + assert.equal(snapshot.sessions.length, 0); + }), + ), +); + +it.effect("refresh recovers from an undecodable cached row and still parses on-disk files", () => + runInTemp((projectsBase) => + Effect.gen(function* () { + yield* seedStaleCacheRow("/old/stale.jsonl"); + yield* seedFile(projectsBase, TEST_DIR, "s", apiResponseLine(1000, 1, TEST_CWD)); + const scanner = yield* StatsScanner; + // Today refresh reads cachedRows (listAll) FIRST and dies before any upsert; the + // fix should let it skip the stale row and still compute the real session. + const snapshot = yield* scanner.refresh(); + assert.isTrue(snapshot.sessions.some((session) => session.projectId === TEST_DIR)); + }), + ), +); diff --git a/apps/server/tests/ru-fork/stats/telemetry.test.ts b/apps/server/tests/ru-fork/stats/telemetry.test.ts new file mode 100644 index 00000000000..500031ddefe --- /dev/null +++ b/apps/server/tests/ru-fork/stats/telemetry.test.ts @@ -0,0 +1,137 @@ +import { assert, it } from "@effect/vitest"; + +import { extractFileTelemetry } from "../../../src/ru-fork/stats/telemetry.ts"; + +// One ui_telemetry system record around a given uiEvent. +const telemetryLine = (uiEvent: Record, base: Record = {}) => + JSON.stringify({ + type: "system", + subtype: "ui_telemetry", + cwd: "/Users/u/WORKSPACE/Projects/app", + gitBranch: "main", + sessionId: "sess-1", + timestamp: "2026-06-10T10:00:00.000Z", + ...base, + systemPayload: { uiEvent }, + }); + +const apiResponse = (overrides: Record = {}) => ({ + "event.name": "qwen-code.api_response", + "event.timestamp": "2026-06-10T10:00:00.000Z", + model: "qwen/qwen3.6-35b-a3b", + input_token_count: 1000, + output_token_count: 50, + cached_content_token_count: 0, + thoughts_token_count: 5, + duration_ms: 8000, + prompt_id: "sess-1########1", + ...overrides, +}); + +it("extracts api_response token + latency fields", () => { + const { events } = extractFileTelemetry(telemetryLine(apiResponse())); + assert.equal(events.length, 1); + const [event] = events; + assert.equal(event.kind, "api_response"); + if (event.kind !== "api_response") return; + assert.equal(event.inputTokens, 1000); + assert.equal(event.outputTokens, 50); + assert.equal(event.thinkingTokens, 5); + assert.equal(event.cachedTokens, 0); + assert.equal(event.durationMs, 8000); + assert.equal(event.model, "qwen/qwen3.6-35b-a3b"); + assert.equal(event.promptId, "sess-1########1"); +}); + +it("defaults missing numeric fields to 0", () => { + const { events } = extractFileTelemetry( + telemetryLine({ "event.name": "qwen-code.api_response", "event.timestamp": "2026-06-10T10:00:00.000Z" }), + ); + const [event] = events; + if (event.kind !== "api_response") throw new Error("expected api_response"); + assert.equal(event.inputTokens, 0); + assert.equal(event.outputTokens, 0); + assert.equal(event.model, undefined); +}); + +it("extracts tool_call success + decision", () => { + const text = [ + telemetryLine({ "event.name": "qwen-code.tool_call", "event.timestamp": "2026-06-10T10:00:01.000Z", function_name: "write_file", success: true, decision: "auto_accept" }), + telemetryLine({ "event.name": "qwen-code.tool_call", "event.timestamp": "2026-06-10T10:00:02.000Z", function_name: "run_shell_command", success: false, decision: "reject" }), + ].join("\n"); + const { events } = extractFileTelemetry(text); + assert.equal(events.length, 2); + assert.deepEqual( + events.map((event) => (event.kind === "tool_call" ? [event.functionName, event.success, event.decision] : null)), + [["write_file", true, "auto_accept"], ["run_shell_command", false, "reject"]], + ); +}); + +it("drops a tool_call with no function_name", () => { + const { events } = extractFileTelemetry( + telemetryLine({ "event.name": "qwen-code.tool_call", "event.timestamp": "2026-06-10T10:00:01.000Z" }), + ); + assert.equal(events.length, 0); +}); + +it("defaults tool_call success to true and decision to undefined when absent", () => { + const { events } = extractFileTelemetry( + telemetryLine({ "event.name": "qwen-code.tool_call", "event.timestamp": "2026-06-10T10:00:01.000Z", function_name: "read_file" }), + ); + const [event] = events; + if (event.kind !== "tool_call") throw new Error("expected tool_call"); + assert.equal(event.success, true); + assert.equal(event.decision, undefined); +}); + +it("extracts api_error type and defaults unknown", () => { + const text = [ + telemetryLine({ "event.name": "qwen-code.api_error", "event.timestamp": "2026-06-10T10:00:03.000Z", error_type: "APIUserAbortError" }), + telemetryLine({ "event.name": "qwen-code.api_error", "event.timestamp": "2026-06-10T10:00:04.000Z" }), + ].join("\n"); + const { events } = extractFileTelemetry(text); + assert.deepEqual( + events.map((event) => (event.kind === "api_error" ? event.errorType : null)), + ["APIUserAbortError", "UnknownError"], + ); +}); + +it("ignores unknown ui_telemetry event names and non-telemetry records", () => { + const text = [ + telemetryLine({ "event.name": "qwen-code.next_speaker_check", "event.timestamp": "2026-06-10T10:00:00.000Z" }), + JSON.stringify({ type: "user", message: { parts: [] }, cwd: "/x", sessionId: "s", timestamp: "t" }), + ].join("\n"); + const { events } = extractFileTelemetry(text); + assert.equal(events.length, 0); +}); + +it("skips malformed + blank lines without throwing (one bad line never poisons)", () => { + const text = ["{ not json", "", " ", telemetryLine(apiResponse())].join("\n"); + const { events } = extractFileTelemetry(text); + assert.equal(events.length, 1); +}); + +it("captures cwd (first), branch (last), sessionId (first)", () => { + const text = [ + telemetryLine(apiResponse(), { cwd: "/a", gitBranch: "feat/x", sessionId: "s1" }), + telemetryLine(apiResponse(), { cwd: "/b", gitBranch: "main", sessionId: "s2" }), + ].join("\n"); + const { cwd, branch, sessionId } = extractFileTelemetry(text); + assert.equal(cwd, "/a"); + assert.equal(branch, "main"); + assert.equal(sessionId, "s1"); +}); + +it("returns empty telemetry for empty text", () => { + const result = extractFileTelemetry(""); + assert.equal(result.events.length, 0); + assert.equal(result.cwd, undefined); +}); + +it("captures the first user message text (string and {parts} shapes)", () => { + const partsLine = JSON.stringify({ type: "user", message: { role: "user", parts: [{ text: "You write concise titles" }] } }); + assert.equal(extractFileTelemetry(partsLine).firstUserText, "You write concise titles"); + const stringLine = JSON.stringify({ type: "user", message: "hello there" }); + assert.equal(extractFileTelemetry(stringLine).firstUserText, "hello there"); + assert.equal(extractFileTelemetry(telemetryLine(apiResponse())).firstUserText, undefined); +}); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 565e1643899..1880993bb76 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -7,6 +7,8 @@ import { type LocalApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, + STATS_GET_SNAPSHOT_METHOD, + STATS_REFRESH_METHOD, TRANSCRIPT_WS_METHOD, WS_METHODS, } from "@t3tools/contracts"; @@ -163,6 +165,11 @@ export interface WsRpcClient { readonly setActiveProject: RpcUnaryMethod; readonly recheck: RpcUnaryMethod; }; + // ru-fork: stats (analytics) — pure-read snapshot + disk refresh. + readonly stats: { + readonly getSnapshot: RpcUnaryMethod; + readonly refresh: RpcUnaryMethod; + }; } export function createWsRpcClient(transport: WsTransport): WsRpcClient { @@ -346,5 +353,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.mcpSetActiveProject](input)), recheck: (input) => transport.request((client) => client[WS_METHODS.mcpRecheck](input)), }, + stats: { + getSnapshot: (input) => + transport.request((client) => client[STATS_GET_SNAPSHOT_METHOD](input)), + refresh: (input) => transport.request((client) => client[STATS_REFRESH_METHOD](input)), + }, }; } diff --git a/apps/web/src/ru-fork/stats/components/RefreshControl.tsx b/apps/web/src/ru-fork/stats/components/RefreshControl.tsx index 4b0a1b4c387..72425c3c15b 100644 --- a/apps/web/src/ru-fork/stats/components/RefreshControl.tsx +++ b/apps/web/src/ru-fork/stats/components/RefreshControl.tsx @@ -1,21 +1,18 @@ /** - * ru-fork: Analytics — refresh cadence control. Manual refresh icon (re-rolls - * the demo data so it feels live), the "обновлено N назад" stamp, and the - * update-interval setting (default 30 мин) that also drives an auto-refresh tick. + * ru-fork: Analytics — refresh control. Manual ⟳ icon (forces a server + * `stats.refresh` via the store nonce) and the "обновлено N назад" stamp. There is + * no auto-refresh — data loads only on open and on this button. * * @module ru-fork/stats/components/RefreshControl */ import { useEffect, useState } from "react"; -import { RefreshCwIcon, TimerIcon } from "lucide-react"; +import { RefreshCwIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; -import { REFRESH_INTERVAL_OPTIONS, useStatsStore } from "../store"; -import { FilterSelect } from "./primitives"; +import { useStatsStore } from "../store"; const RELATIVE_TIME_TICK_MS = 15_000; -const REFRESH_SPINNER_MS = 700; -const MILLISECONDS_PER_MINUTE = 60_000; function useRelativeTime(sinceMs: number): string { const [, forceRerender] = useState(0); @@ -24,6 +21,7 @@ function useRelativeTime(sinceMs: number): string { return () => clearInterval(intervalId); }, []); + if (sinceMs === 0) return "—"; const elapsedSeconds = Math.max(0, Math.floor((Date.now() - sinceMs) / 1000)); if (elapsedSeconds < 30) return "только что"; if (elapsedSeconds < 90) return "минуту назад"; @@ -34,26 +32,11 @@ function useRelativeTime(sinceMs: number): string { } export function RefreshControl() { - const isRefreshing = useStatsStore((state) => state.isRefreshing); + const status = useStatsStore((state) => state.status); const lastRefreshedAtMs = useStatsStore((state) => state.lastRefreshedAtMs); - const refreshIntervalMin = useStatsStore((state) => state.refreshIntervalMin); - const startRefresh = useStatsStore((state) => state.startRefresh); - const finishRefresh = useStatsStore((state) => state.finishRefresh); - const setRefreshIntervalMin = useStatsStore((state) => state.setRefreshIntervalMin); + const requestRefresh = useStatsStore((state) => state.requestRefresh); const relativeTime = useRelativeTime(lastRefreshedAtMs); - - // Resolve the spinner shortly after a refresh starts. - useEffect(() => { - if (!isRefreshing) return; - const timeoutId = setTimeout(() => finishRefresh(), REFRESH_SPINNER_MS); - return () => clearTimeout(timeoutId); - }, [isRefreshing, finishRefresh]); - - // Auto-refresh on the configured cadence. - useEffect(() => { - const intervalId = setInterval(() => startRefresh(), refreshIntervalMin * MILLISECONDS_PER_MINUTE); - return () => clearInterval(intervalId); - }, [refreshIntervalMin, startRefresh]); + const isRefreshing = status === "loading"; return (
@@ -61,20 +44,13 @@ export function RefreshControl() { - setRefreshIntervalMin(Number(nextValue))} - options={REFRESH_INTERVAL_OPTIONS.map((option) => ({ value: String(option.value), label: option.label }))} - />
); } diff --git a/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx b/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx index 461071f2ecd..4665b4ad21e 100644 --- a/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx +++ b/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx @@ -6,7 +6,7 @@ import { Badge } from "~/components/ui/badge"; import { Sheet, SheetDescription, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "~/components/ui/sheet"; import { formatDateTime, formatDuration, formatInt, formatTokens } from "../model/format"; -import type { StatsSession, StatsView } from "../model/types"; +import { CATEGORY_LABEL, type StatsSession, type StatsView } from "../model/types"; import { findSessionById, useStatsStore } from "../store"; import { BarRow } from "./primitives"; @@ -37,9 +37,9 @@ function SessionDetailBody({ session }: { session: StatsSession }) { {formatDateTime(session.startedAt)}
{session.branch} - {session.model.replace("qwen/", "")} + {session.model} {session.projectKind === "temp" ? песочница : null} - {session.isBackground ? фон : null} + {CATEGORY_LABEL[session.category]}
diff --git a/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx b/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx index 036d94204e3..6a096d28a77 100644 --- a/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx +++ b/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx @@ -1,10 +1,11 @@ /** * ru-fork: Analytics dashboard — the Settings → «Аналитика» panel. * - * Read-only, fake-data view (no server). Composes the filter bar, KPI strip and - * the widget grid; every card can expand into a drill-down sheet, and the - * sessions table opens a per-session sheet. All numbers derive from {@link - * useStatsView} so a filter change re-renders the whole board consistently. + * Read-only view over the server stats snapshot. Composes the filter bar, KPI + * strip and the widget grid; every card can expand into a drill-down sheet, and + * the sessions table opens a per-session sheet. All numbers derive from {@link + * useStatsView} so a filter change re-renders the whole board consistently; + * {@link useStatsData} keeps the session set in sync with the server. * * @module ru-fork/stats/components/StatsDashboard */ @@ -12,6 +13,7 @@ import { ChartColumnIcon } from "lucide-react"; import { ScrollArea } from "~/components/ui/scroll-area"; import { useStatsStore, useStatsView } from "../store"; +import { useStatsData } from "../useStatsData"; import { KpiStrip } from "./KpiStrip"; import { RefreshControl } from "./RefreshControl"; import { SessionDetailSheet } from "./SessionDetailSheet"; @@ -27,7 +29,10 @@ import { ToolsCard } from "./widgets/ToolsCard"; import { UsageOverTimeCard } from "./widgets/UsageOverTimeCard"; export function StatsDashboard() { + useStatsData(); const view = useStatsView(); + const status = useStatsStore((state) => state.status); + const errorDetail = useStatsStore((state) => state.errorDetail); const granularity = useStatsStore((state) => state.granularity); const setGranularity = useStatsStore((state) => state.setGranularity); const setFilters = useStatsStore((state) => state.setFilters); @@ -53,6 +58,15 @@ export function StatsDashboard() { + {status === "error" ? ( +

+ {errorDetail ?? "Не удалось обновить статистику"} +

+ ) : null} + {status === "loading" && view.sessions.length === 0 ? ( +

Загрузка статистики…

+ ) : null} +
state.filters); const setFilters = useStatsStore((state) => state.setFilters); const resetFilters = useStatsStore((state) => state.resetFilters); + const sessions = useStatsStore((state) => state.sessions); + const lastRefreshedAtMs = useStatsStore((state) => state.lastRefreshedAtMs); + + // Faceted options: each dropdown lists only values present once the OTHER active + // filters are applied (its own dimension neutralized to "all"), anchored to the + // same time the dashboard uses. + const anchorMs = lastRefreshedAtMs > 0 ? lastRefreshedAtMs : Date.now(); + const projectChoices = useMemo( + () => + projectOptions( + filterSessions(sessions, { ...filters, projectId: "all" }, anchorMs), + sessions, + filters.projectId, + ), + [sessions, filters, anchorMs], + ); + const modelChoices = useMemo( + () => modelOptions(filterSessions(sessions, { ...filters, model: "all" }, anchorMs), filters.model), + [sessions, filters, anchorMs], + ); + const branchChoices = useMemo( + () => branchOptions(filterSessions(sessions, { ...filters, branch: "all" }, anchorMs), filters.branch), + [sessions, filters, anchorMs], + ); const hasActiveFilters = filters.rangeDays !== DEFAULT_FILTERS.rangeDays || @@ -48,48 +66,61 @@ export function StatsFilterBar() { filters.traffic !== DEFAULT_FILTERS.traffic; return ( -
- setFilters({ rangeDays: parseRangeDays(nextValue) })} - options={RANGE_OPTIONS.map((option) => ({ value: String(option.value), label: option.label }))} - /> - setFilters({ projectId })} - options={PROJECT_OPTIONS} - /> - setFilters({ model })} - options={MODEL_OPTIONS} - /> - setFilters({ branch })} - options={BRANCH_OPTIONS} - /> +
+
+ setFilters({ rangeDays: parseRangeDays(nextValue) })} + options={RANGE_OPTIONS} + /> + setFilters({ projectId })} + options={projectChoices} + /> + setFilters({ model })} + options={modelChoices} + /> + setFilters({ branch })} + options={branchChoices} + /> - setFilters({ traffic })} /> + setFilters({ traffic })} + className="w-full [&>button]:flex-1" + /> - + +
{hasActiveFilters ? ( - +
+ +
) : null}
); diff --git a/apps/web/src/ru-fork/stats/components/primitives.tsx b/apps/web/src/ru-fork/stats/components/primitives.tsx index 50fefb8f024..7f617447ac0 100644 --- a/apps/web/src/ru-fork/stats/components/primitives.tsx +++ b/apps/web/src/ru-fork/stats/components/primitives.tsx @@ -144,7 +144,7 @@ export function FilterSelect({ value, options, onChange, icon: LeadingIcon, aria {LeadingIcon ? : null} - + {options.map((option) => ( {option.label} diff --git a/apps/web/src/ru-fork/stats/components/widgets/ModelsCard.tsx b/apps/web/src/ru-fork/stats/components/widgets/ModelsCard.tsx index b1c3f777a77..16a0b47a81f 100644 --- a/apps/web/src/ru-fork/stats/components/widgets/ModelsCard.tsx +++ b/apps/web/src/ru-fork/stats/components/widgets/ModelsCard.tsx @@ -24,7 +24,7 @@ export function ModelsCard({ models, onExpand }: ModelsCardProps) { return ( -
+
} /> @@ -44,7 +44,7 @@ export function ModelsCard({ models, onExpand }: ModelsCardProps) { -
    +
      {models.map((model, modelIndex) => (
    • diff --git a/apps/web/src/ru-fork/stats/components/widgets/SessionsTableCard.tsx b/apps/web/src/ru-fork/stats/components/widgets/SessionsTableCard.tsx index def993ded94..9312a16d386 100644 --- a/apps/web/src/ru-fork/stats/components/widgets/SessionsTableCard.tsx +++ b/apps/web/src/ru-fork/stats/components/widgets/SessionsTableCard.tsx @@ -7,7 +7,7 @@ import { ChevronRightIcon, TableIcon } from "lucide-react"; import { Badge } from "~/components/ui/badge"; import { formatDateTime, formatDuration, formatInt, formatTokens } from "../../model/format"; -import type { StatsSession } from "../../model/types"; +import { CATEGORY_LABEL, type StatsSession } from "../../model/types"; import { WidgetCard } from "../primitives"; function sumRecordValues(record: Readonly>): number { @@ -44,6 +44,7 @@ export function SessionsTableCard({ sessions, onSelect, onExpand, limit = 10 }: Дата Проект + Тип Ветка Токены Ходы @@ -66,9 +67,13 @@ export function SessionsTableCard({ sessions, onSelect, onExpand, limit = 10 }: {session.projectLabel} {session.projectKind === "temp" ? temp : null} - {session.isBackground ? фон : null} + + + {CATEGORY_LABEL[session.category]} + + {session.branch} {formatTokens(visibleTokenTotal(session))} {session.turns} @@ -87,7 +92,7 @@ export function SessionsTableCard({ sessions, onSelect, onExpand, limit = 10 }: })} {visibleSessions.length === 0 ? ( - + Нет сессий под текущие фильтры diff --git a/apps/web/src/ru-fork/stats/index.ts b/apps/web/src/ru-fork/stats/index.ts index 9568fd4612a..e12784cecb9 100644 --- a/apps/web/src/ru-fork/stats/index.ts +++ b/apps/web/src/ru-fork/stats/index.ts @@ -1,10 +1,10 @@ /** * ru-fork: Analytics (stats) — public surface. * - * A read-only Settings panel that scans the CLI's per-project chat transcripts - * and surfaces usage analytics (tokens, models, tools, reliability, activity). - * Currently driven by fake/demo data — no server logic — but the model shapes - * mirror the real on-disk qwen telemetry so a real loader is a drop-in later. + * A read-only Settings panel that surfaces usage analytics (tokens, models, + * tools, reliability, activity) from the server's stats engine, which scans the + * CLI's per-project chat transcripts. The dashboard fetches one StatsSession per + * chat file via `stats.getSnapshot` and aggregates them client-side. * * @module ru-fork/stats */ diff --git a/apps/web/src/ru-fork/stats/model/catalog.ts b/apps/web/src/ru-fork/stats/model/catalog.ts index 6fb94ccc185..da74bd5fa8c 100644 --- a/apps/web/src/ru-fork/stats/model/catalog.ts +++ b/apps/web/src/ru-fork/stats/model/catalog.ts @@ -1,96 +1,32 @@ /** - * ru-fork: Analytics — the dimension catalog the fake generator samples from. - * Weights/labels are lifted from the real `projects/` scan so the demo reads - * like genuine usage (model, project mix, tool histogram, error types, branches). - * - * Each catalog is typed as a non-empty tuple so the weighted picker can return a - * definite element without any cast. + * ru-fork: Analytics — static tool→group reference (names + groups + labels). + * Used to colour/label tools and to bucket unknown tools. All *data* now comes + * from the server; this is presentation reference only. * * @module ru-fork/stats/model/catalog */ -import type { ProjectKind, ToolGroup } from "./types"; - -/** Anything the weighted random picker can choose between. */ -export interface Weighted { - readonly weight: number; -} - -export interface ProjectDefinition extends Weighted { - readonly projectId: string; - readonly label: string; - readonly path: string; - readonly kind: ProjectKind; -} - -/** Real projects (+ a folded "sandbox" group for the many temp dirs). */ -export const PROJECTS: readonly [ProjectDefinition, ...ProjectDefinition[]] = [ - { projectId: "ai-playground", label: "ai-playground", path: "~/WORKSPACE/Projects/ai-playground", kind: "real", weight: 38 }, - { projectId: "atomic-code", label: "atomic-code", path: "~/WORKSPACE/Projects/experements/atomic-code", kind: "real", weight: 24 }, - { projectId: "t3code", label: "t3code", path: "~/WORKSPACE/Projects/experements/t3code", kind: "real", weight: 20 }, - { projectId: "test1", label: "test1", path: "~/WORKSPACE/test1", kind: "real", weight: 6 }, - { projectId: "server", label: "t3code-apps-server", path: "~/WORKSPACE/Projects/experements/t3code-apps-server", kind: "real", weight: 3 }, - { projectId: "test3", label: "test3", path: "~/WORKSPACE/test3", kind: "real", weight: 2 }, - { projectId: "sandbox", label: "Песочница / temp", path: "/var/folders/…/acp-test-*", kind: "temp", weight: 14 }, -]; - -export interface ModelDefinition extends Weighted { - readonly modelId: string; - readonly label: string; - readonly contextWindow: number; -} - -/** qwen3.6-35b dominates the real data; the others give the model widget life. */ -export const MODELS: readonly [ModelDefinition, ...ModelDefinition[]] = [ - { modelId: "qwen/qwen3.6-35b-a3b", label: "qwen3.6-35b-a3b", contextWindow: 60_000, weight: 72 }, - { modelId: "qwen/qwen3.6-coder-7b", label: "qwen3.6-coder-7b", contextWindow: 32_000, weight: 16 }, - { modelId: "qwen/qwen3.6-72b-a14b", label: "qwen3.6-72b-a14b", contextWindow: 128_000, weight: 12 }, -]; - -export interface BranchDefinition extends Weighted { - readonly name: string; -} - -export const BRANCHES: readonly [BranchDefinition, ...BranchDefinition[]] = [ - { name: "ru-code", weight: 30 }, - { name: "feat/qwen", weight: 26 }, - { name: "main", weight: 20 }, - { name: "feat/public", weight: 14 } -]; +import type { ToolGroup } from "./types"; -export interface ToolDefinition extends Weighted { +export interface ToolGroupEntry { readonly name: string; readonly group: ToolGroup; - /** Baseline success probability (mirrors the real per-tool ok-rate). */ - readonly successRate: number; -} - -export const TOOLS: readonly [ToolDefinition, ...ToolDefinition[]] = [ - { name: "ask_user_question", group: "flow", weight: 159, successRate: 0.61 }, - { name: "run_shell_command", group: "shell", weight: 132, successRate: 0.91 }, - { name: "write_file", group: "fs", weight: 115, successRate: 0.85 }, - { name: "read_file", group: "fs", weight: 115, successRate: 1.0 }, - { name: "todo_write", group: "flow", weight: 80, successRate: 0.99 }, - { name: "skill", group: "agent", weight: 69, successRate: 0.65 }, - { name: "exit_plan_mode", group: "flow", weight: 67, successRate: 0.18 }, - { name: "grep_search", group: "search", weight: 53, successRate: 1.0 }, - { name: "glob", group: "search", weight: 53, successRate: 0.85 }, - { name: "agent", group: "agent", weight: 49, successRate: 0.96 }, - { name: "list_directory", group: "fs", weight: 30, successRate: 0.87 }, - { name: "web_fetch", group: "web", weight: 22, successRate: 0.73 }, - { name: "edit", group: "fs", weight: 22, successRate: 0.95 }, - { name: "mcp__context7__query-docs", group: "mcp", weight: 6, successRate: 1.0 }, - { name: "mcp__context7__resolve-library-id", group: "mcp", weight: 6, successRate: 1.0 }, -]; - -export interface ErrorTypeDefinition extends Weighted { - readonly type: string; } -export const ERROR_TYPES: readonly [ErrorTypeDefinition, ...ErrorTypeDefinition[]] = [ - { type: "APIUserAbortError", weight: 28 }, - { type: "APIError", weight: 12 }, - { type: "BadRequestError", weight: 8 }, - { type: "APIConnectionError", weight: 2 }, +/** Known native/MCP tools → their group. Unknown tools fall back in selectors. */ +export const TOOLS: readonly ToolGroupEntry[] = [ + { name: "ask_user_question", group: "flow" }, + { name: "run_shell_command", group: "shell" }, + { name: "write_file", group: "fs" }, + { name: "read_file", group: "fs" }, + { name: "todo_write", group: "flow" }, + { name: "skill", group: "agent" }, + { name: "exit_plan_mode", group: "flow" }, + { name: "grep_search", group: "search" }, + { name: "glob", group: "search" }, + { name: "agent", group: "agent" }, + { name: "list_directory", group: "fs" }, + { name: "web_fetch", group: "web" }, + { name: "edit", group: "fs" }, ]; export const TOOL_GROUP_LABEL: Record = { diff --git a/apps/web/src/ru-fork/stats/model/fakeData.ts b/apps/web/src/ru-fork/stats/model/fakeData.ts deleted file mode 100644 index 2edbfc065d4..00000000000 --- a/apps/web/src/ru-fork/stats/model/fakeData.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * ru-fork: Analytics — the demo dataset + filter-option helpers. - * - * `BASE_SESSIONS` is generated once (stable seed). `sessionsForSeed` lets the - * "refresh" control re-roll a near-identical dataset so the dashboard feels - * live (numbers nudge, ordering wobbles) without any backend. - * - * @module ru-fork/stats/model/fakeData - */ -import { MODELS, PROJECTS } from "./catalog"; -import { DEMO_TODAY, generateSessions } from "./generateSessions"; -import type { StatsSession } from "./types"; - -export { DEMO_TODAY }; - -export const BASE_SESSIONS: readonly StatsSession[] = generateSessions(); - -/** Re-roll with a different seed (used by the manual refresh to feel live). */ -export function sessionsForSeed(seed: number): readonly StatsSession[] { - return generateSessions(0xc0ffee ^ (seed * 2654435761)); -} - -export interface FilterOption { - readonly value: string; - readonly label: string; -} - -export const PROJECT_OPTIONS: readonly FilterOption[] = [ - { value: "all", label: "Все проекты" }, - ...PROJECTS.map((project) => ({ value: project.projectId, label: project.label })), -]; - -export const MODEL_OPTIONS: readonly FilterOption[] = [ - { value: "all", label: "Все модели" }, - ...MODELS.map((model) => ({ value: model.modelId, label: model.label })), -]; - -export const BRANCH_OPTIONS: readonly FilterOption[] = [ - { value: "all", label: "Все ветки" }, - ...Array.from(new Set(BASE_SESSIONS.map((session) => session.branch))).map((branch) => ({ - value: branch, - label: branch, - })), -]; - -export interface RangeOption { - readonly value: number; - readonly label: string; -} - -export const RANGE_OPTIONS: readonly RangeOption[] = [ - { value: 7, label: "7 дней" }, - { value: 14, label: "14 дней" }, - { value: 30, label: "30 дней" }, - { value: 48, label: "Всё время" }, -]; diff --git a/apps/web/src/ru-fork/stats/model/filterOptions.ts b/apps/web/src/ru-fork/stats/model/filterOptions.ts new file mode 100644 index 00000000000..ebba9bac080 --- /dev/null +++ b/apps/web/src/ru-fork/stats/model/filterOptions.ts @@ -0,0 +1,79 @@ +/** + * ru-fork: Analytics — filter dropdown options. Each list is faceted: it's derived + * from the sessions that already pass the OTHER active filters (time range, sandbox, + * traffic, the other two dimensions), so every option you can pick returns data. The + * currently-selected value is always kept present so the control never goes blank. + * + * @module ru-fork/stats/model/filterOptions + */ +import type { StatsSession } from "./types"; + +export interface FilterOption { + readonly value: string; + readonly label: string; +} + +export interface RangeOption { + readonly value: string; + readonly label: string; +} + +export const RANGE_OPTIONS: readonly RangeOption[] = [ + { value: "1", label: "Сегодня" }, + { value: "7", label: "7 дней" }, + { value: "14", label: "14 дней" }, + { value: "30", label: "30 дней" }, + { value: "all", label: "Всё время" }, +]; + +const distinct = (values: ReadonlyArray): ReadonlyArray => + Array.from(new Set(values.filter((value) => value.length > 0))).toSorted(); + +export function projectOptions( + scoped: ReadonlyArray, + all: ReadonlyArray, + selected: string, +): readonly FilterOption[] { + const labelById = new Map(); + for (const session of scoped) labelById.set(session.projectId, session.projectLabel); + // Keep the active selection visible even if it has no data in the current window. + if (selected !== "all" && !labelById.has(selected)) { + const known = all.find((session) => session.projectId === selected); + labelById.set(selected, known?.projectLabel ?? selected); + } + const entries = Array.from(labelById.entries()).toSorted((first, second) => + first[1].localeCompare(second[1]), + ); + return [ + { value: "all", label: "Все проекты" }, + ...entries.map(([value, label]) => ({ value, label })), + ]; +} + +export function modelOptions( + scoped: ReadonlyArray, + selected: string, +): readonly FilterOption[] { + const models = new Set(distinct(scoped.map((session) => session.model))); + if (selected !== "all") models.add(selected); + return [ + { value: "all", label: "Все модели" }, + ...Array.from(models) + .toSorted() + .map((model) => ({ value: model, label: model })), + ]; +} + +export function branchOptions( + scoped: ReadonlyArray, + selected: string, +): readonly FilterOption[] { + const branches = new Set(distinct(scoped.map((session) => session.branch))); + if (selected !== "all") branches.add(selected); + return [ + { value: "all", label: "Все ветки" }, + ...Array.from(branches) + .toSorted() + .map((branch) => ({ value: branch, label: branch })), + ]; +} diff --git a/apps/web/src/ru-fork/stats/model/format.ts b/apps/web/src/ru-fork/stats/model/format.ts index 1db61760ac6..e47abe9a32e 100644 --- a/apps/web/src/ru-fork/stats/model/format.ts +++ b/apps/web/src/ru-fork/stats/model/format.ts @@ -60,11 +60,13 @@ export function formatDayLabel(isoString: string): string { return `${date.getUTCDate()} ${monthShort(date.getUTCMonth())}`; } +/** Local "DD mon, HH:MM" of an instant — shown in the browser's local timezone, matching + * the local-day windowing (an instant is one moment, displayed where the viewer is). */ export function formatDateTime(isoString: string): string { const date = new Date(isoString); - const hours = `${date.getUTCHours()}`.padStart(2, "0"); - const minutes = `${date.getUTCMinutes()}`.padStart(2, "0"); - return `${date.getUTCDate()} ${monthShort(date.getUTCMonth())}, ${hours}:${minutes}`; + const hours = `${date.getHours()}`.padStart(2, "0"); + const minutes = `${date.getMinutes()}`.padStart(2, "0"); + return `${date.getDate()} ${monthShort(date.getMonth())}, ${hours}:${minutes}`; } export const WEEKDAY_LABELS: readonly string[] = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; diff --git a/apps/web/src/ru-fork/stats/model/generateSessions.ts b/apps/web/src/ru-fork/stats/model/generateSessions.ts deleted file mode 100644 index d3c9dd1a539..00000000000 --- a/apps/web/src/ru-fork/stats/model/generateSessions.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * ru-fork: Analytics — deterministic fake-session generator. - * - * A seeded PRNG (mulberry32) makes the dataset stable across reloads so the - * dashboard looks the same every time (and snapshots/screenshots are diffable). - * Timestamps hang off a fixed "today" so the demo never drifts. Pure: no I/O. - * - * @module ru-fork/stats/model/generateSessions - */ -import { BRANCHES, ERROR_TYPES, MODELS, PROJECTS, TOOLS, type Weighted } from "./catalog"; -import type { StatsSession } from "./types"; - -/** Anchor day for the whole demo (matches the real scan's last day). */ -export const DEMO_TODAY = "2026-06-17"; -const MILLISECONDS_PER_DAY = 86_400_000; -const MILLISECONDS_PER_HOUR = 3_600_000; -const GENERATED_SPAN_DAYS = 48; -const GENERATED_SESSION_COUNT = 168; - -/** Returns a deterministic [0, 1) generator seeded from `seed`. */ -function createSeededRandom(seed: number): () => number { - let state = seed >>> 0; - return () => { - state |= 0; - state = (state + 0x6d2b79f5) | 0; - let mixed = Math.imul(state ^ (state >>> 15), 1 | state); - mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed; - return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Pick one element with probability proportional to its weight. */ -function pickWeighted( - random: () => number, - items: readonly [Item, ...Item[]], -): Item { - const totalWeight = items.reduce((runningWeight, candidate) => runningWeight + candidate.weight, 0); - let threshold = random() * totalWeight; - let chosen = items[0]; - for (const candidate of items) { - chosen = candidate; - threshold -= candidate.weight; - if (threshold <= 0) break; - } - return chosen; -} - -function randomInt(random: () => number, minimum: number, maximum: number): number { - return Math.floor(minimum + random() * (maximum - minimum + 1)); -} - -/** Heavier activity on working/evening hours so the heatmap looks real. */ -function pickActiveHour(random: () => number): number { - const activeHours: readonly number[] = [9, 10, 11, 14, 15, 16, 17, 21, 22, 23, 1]; - if (random() < 0.72) { - const position = randomInt(random, 0, activeHours.length - 1); - return activeHours.at(position) ?? randomInt(random, 0, 23); - } - return randomInt(random, 0, 23); -} - -interface ToolUsage { - readonly callsByTool: Record; - readonly failuresByTool: Record; - readonly totalCalls: number; - readonly failedCalls: number; -} - -function buildToolUsage(random: () => number, intensity: number): ToolUsage { - const callsByTool: Record = {}; - const failuresByTool: Record = {}; - let totalCalls = 0; - let failedCalls = 0; - const distinctToolPicks = randomInt(random, 2, 6) + Math.round(intensity * 4); - for (let pickIndex = 0; pickIndex < distinctToolPicks; pickIndex += 1) { - const tool = pickWeighted(random, TOOLS); - const callCount = randomInt(random, 1, 4); - callsByTool[tool.name] = (callsByTool[tool.name] ?? 0) + callCount; - totalCalls += callCount; - let failuresForTool = 0; - for (let attempt = 0; attempt < callCount; attempt += 1) { - if (random() > tool.successRate) failuresForTool += 1; - } - if (failuresForTool > 0) { - failuresByTool[tool.name] = (failuresByTool[tool.name] ?? 0) + failuresForTool; - failedCalls += failuresForTool; - } - } - return { callsByTool, failuresByTool, totalCalls, failedCalls }; -} - -const EMPTY_TOOL_USAGE: ToolUsage = { callsByTool: {}, failuresByTool: {}, totalCalls: 0, failedCalls: 0 }; - -export function generateSessions(seed = 0xc0ffee): readonly StatsSession[] { - const random = createSeededRandom(seed); - const anchorMs = Date.parse(`${DEMO_TODAY}T23:59:59.000Z`); - const sessions: StatsSession[] = []; - - for (let sessionIndex = 0; sessionIndex < GENERATED_SESSION_COUNT; sessionIndex += 1) { - const project = pickWeighted(random, PROJECTS); - const model = pickWeighted(random, MODELS); - const branch = pickWeighted(random, BRANCHES); - - // Recency bias: more sessions in the recent weeks. - const dayOffset = Math.floor(random() ** 1.7 * GENERATED_SPAN_DAYS); - const hour = pickActiveHour(random); - const startedAtMs = - anchorMs - - dayOffset * MILLISECONDS_PER_DAY - - (24 - hour) * MILLISECONDS_PER_HOUR - - randomInt(random, 0, 3_540_000) * 1000; - const startedAt = new Date(startedAtMs).toISOString(); - - const isBackground = random() < 0.06; - const intensity = random() ** 1.5; // 0..1 skewed small - const turns = isBackground ? 1 : randomInt(random, 1, 4) + Math.round(intensity * 22); - const apiCalls = isBackground ? 1 : turns + randomInt(random, 0, turns); - - // Input dominates (context re-sent each call); output is small. - const inputPerCall = randomInt(random, 9_000, 22_000) + Math.round(intensity * 30_000); - const inputTokens = inputPerCall * apiCalls; - const outputTokens = apiCalls * randomInt(random, 60, 520); - const thinkingTokens = random() < 0.3 ? apiCalls * randomInt(random, 0, 30) : 0; - - const toolUsage = isBackground ? EMPTY_TOOL_USAGE : buildToolUsage(random, intensity); - - const errorTypes: Record = {}; - if (random() < 0.22) { - const errorType = pickWeighted(random, ERROR_TYPES); - errorTypes[errorType.type] = randomInt(random, 1, 2); - } - - const autoAccepted = Math.round(toolUsage.totalCalls * 0.15 * random()); - const rejected = random() < 0.08 ? randomInt(random, 1, 2) : 0; - - const avgLatencyMs = randomInt(random, 4_000, 16_000) + Math.round(intensity * 9_000); - const maxLatencyMs = avgLatencyMs + randomInt(random, 2_000, 60_000); - - sessions.push({ - sessionId: `session-${(sessionIndex + 1).toString().padStart(3, "0")}-${Math.floor(random() * 1e6).toString(36)}`, - projectId: project.projectId, - projectLabel: project.label, - projectPath: project.path, - projectKind: project.kind, - branch: branch.name, - model: model.modelId, - startedAt, - durationMs: Math.max(turns, 1) * randomInt(random, 25_000, 95_000), - turns, - isBackground, - apiCalls, - tokens: { input: inputTokens, output: outputTokens, thinking: thinkingTokens, cached: 0 }, - avgLatencyMs, - maxLatencyMs, - toolCounts: toolUsage.callsByTool, - toolFailures: toolUsage.failuresByTool, - errorTypes, - autoAccepted, - rejected, - }); - } - - return sessions.toSorted((first, second) => second.startedAt.localeCompare(first.startedAt)); -} diff --git a/apps/web/src/ru-fork/stats/model/selectors.ts b/apps/web/src/ru-fork/stats/model/selectors.ts index 756b22d0091..0dc9101d83a 100644 --- a/apps/web/src/ru-fork/stats/model/selectors.ts +++ b/apps/web/src/ru-fork/stats/model/selectors.ts @@ -1,13 +1,17 @@ /** - * ru-fork: Analytics — pure aggregation. Filters narrow the session set; this - * turns the survivors into every widget's view model. One source of truth: change - * a filter and the KPIs, charts, tables and heatmap all recompute from here. + * ru-fork: Analytics — pure aggregation. Filters narrow the session set; this turns + * the survivors into every widget's view model. One source of truth: change a filter + * and the KPIs, charts, tables and heatmap all recompute from here. + * + * Time is handled at the grain the server pre-computed: each session carries + * `tokensByDay` (per UTC day) and `tokensByWeekdayHour`. Token/usage views are summed + * from the in-window days, so a multi-day session contributes to each day it touched + * — no more pinning a whole session to one timestamp. * * @module ru-fork/stats/model/selectors */ import { TOOLS } from "./catalog"; -import { DEMO_TODAY } from "./generateSessions"; -import { dayKey, isoWeekday } from "./format"; +import { isoWeekday } from "./format"; import type { ApprovalSplit, ErrorStat, @@ -16,6 +20,8 @@ import type { KpiSet, LatencyBucket, NamedTokenSlice, + RangeDays, + StatsDayBucket, StatsFilters, StatsSession, StatsView, @@ -26,7 +32,6 @@ import type { } from "./types"; const MILLISECONDS_PER_DAY = 86_400_000; -const ANCHOR_MS = Date.parse(`${DEMO_TODAY}T23:59:59.000Z`); const TOOL_GROUP_BY_NAME: ReadonlyMap = new Map( TOOLS.map((tool) => [tool.name, tool.group]), @@ -35,11 +40,64 @@ function toolGroupForName(toolName: string): ToolGroup { return TOOL_GROUP_BY_NAME.get(toolName) ?? (toolName.startsWith("mcp__") ? "mcp" : "flow"); } -function startsWithinRange(session: StatsSession, rangeDays: number, anchorMs = ANCHOR_MS): boolean { - const cutoffMs = anchorMs - rangeDays * MILLISECONDS_PER_DAY; - return Date.parse(session.startedAt) >= cutoffMs; +// ── time windows (calendar-day aligned, UTC) ──────────────────────────────── + +/** Local "YYYY-MM-DD" of a millisecond instant — the browser's local day, matching the + * server's local-zone day keys (same machine ⇒ same zone). */ +function dayKeyFromMs(milliseconds: number): string { + const date = new Date(milliseconds); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; +} + +/** Earliest in-window day key for a range; null ⇒ "all" (no cutoff). */ +function cutoffDayKey(rangeDays: RangeDays, anchorMs: number): string | null { + if (rangeDays === "all") return null; + return dayKeyFromMs(anchorMs - (rangeDays - 1) * MILLISECONDS_PER_DAY); +} + +/** Sum the session's day buckets that fall on/after the cutoff (all if null). */ +function windowedTokens(session: StatsSession, cutoff: string | null): StatsDayBucket { + let input = 0; + let output = 0; + let thinking = 0; + let cached = 0; + let apiCalls = 0; + for (const [day, bucket] of Object.entries(session.tokensByDay)) { + if (cutoff !== null && day < cutoff) continue; + input += bucket.input; + output += bucket.output; + thinking += bucket.thinking; + cached += bucket.cached; + apiCalls += bucket.apiCalls; + } + return { input, output, thinking, cached, apiCalls }; +} + +/** Does the session have any activity on/after the cutoff (always, when "all")? */ +function hasWindowActivity(session: StatsSession, cutoff: string | null): boolean { + const days = Object.keys(session.tokensByDay); + if (cutoff === null) return days.length > 0; + return days.some((day) => day >= cutoff); +} + +function visibleTotal(tokens: TokenBreakdown): number { + return tokens.input + tokens.output + tokens.thinking; +} + +function bucketVisible(bucket: StatsDayBucket): number { + return bucket.input + bucket.output + bucket.thinking; +} + +function sumRecordValues(record: Readonly>): number { + let total = 0; + for (const value of Object.values(record)) total += value; + return total; } +// ── dimension matching + filtering ────────────────────────────────────────── + function matchesDimensions(session: StatsSession, filters: StatsFilters): boolean { if (!filters.includeTemp && session.projectKind === "temp") return false; if (filters.projectId !== "all" && session.projectId !== filters.projectId) return false; @@ -48,37 +106,60 @@ function matchesDimensions(session: StatsSession, filters: StatsFilters): boolea return true; } +function matchesTraffic(session: StatsSession, filters: StatsFilters): boolean { + if (filters.traffic === "turns") return !session.isBackground; + if (filters.traffic === "background") return session.isBackground; + return true; +} + export function filterSessions( sessions: readonly StatsSession[], filters: StatsFilters, + anchorMs: number, ): readonly StatsSession[] { - return sessions.filter((session) => { - if (!matchesDimensions(session, filters)) return false; - if (filters.traffic === "turns" && session.isBackground) return false; - if (filters.traffic === "background" && !session.isBackground) return false; - return startsWithinRange(session, filters.rangeDays); - }); -} - -function sumTokens(sessions: readonly StatsSession[]): TokenBreakdown { - return sessions.reduce( - (accumulator, session) => ({ - input: accumulator.input + session.tokens.input, - output: accumulator.output + session.tokens.output, - thinking: accumulator.thinking + session.tokens.thinking, - cached: accumulator.cached + session.tokens.cached, - }), - { input: 0, output: 0, thinking: 0, cached: 0 }, + const cutoff = cutoffDayKey(filters.rangeDays, anchorMs); + return sessions.filter( + (session) => + matchesDimensions(session, filters) && + matchesTraffic(session, filters) && + hasWindowActivity(session, cutoff), ); } -function visibleTotal(tokens: TokenBreakdown): number { - return tokens.input + tokens.output + tokens.thinking; +// ── token aggregation (windowed) ──────────────────────────────────────────── + +function sumWindowedTokens(sessions: readonly StatsSession[], cutoff: string | null): TokenBreakdown { + let input = 0; + let output = 0; + let thinking = 0; + let cached = 0; + for (const session of sessions) { + const windowed = windowedTokens(session, cutoff); + input += windowed.input; + output += windowed.output; + thinking += windowed.thinking; + cached += windowed.cached; + } + return { input, output, thinking, cached }; } -function sumRecordValues(record: Readonly>): number { +/** Visible tokens in the previous equal-length window (for the trend chips). */ +function previousWindowVisibleTokens( + sessions: readonly StatsSession[], + filters: StatsFilters, + anchorMs: number, +): number { + if (filters.rangeDays === "all") return 0; + const span = filters.rangeDays; + const previousEnd = dayKeyFromMs(anchorMs - span * MILLISECONDS_PER_DAY); + const previousStart = dayKeyFromMs(anchorMs - (2 * span - 1) * MILLISECONDS_PER_DAY); let total = 0; - for (const value of Object.values(record)) total += value; + for (const session of sessions) { + if (!matchesDimensions(session, filters) || !matchesTraffic(session, filters)) continue; + for (const [day, bucket] of Object.entries(session.tokensByDay)) { + if (day >= previousStart && day <= previousEnd) total += bucketVisible(bucket); + } + } return total; } @@ -86,8 +167,13 @@ function buildKpis( allSessions: readonly StatsSession[], filteredSessions: readonly StatsSession[], filters: StatsFilters, + anchorMs: number, + cutoff: string | null, ): KpiSet { - const tokens = sumTokens(filteredSessions); + const tokens = sumWindowedTokens(filteredSessions, cutoff); + // Counts (calls/tools/errors) + latency are session-grain over the in-window sessions; + // only token amounts are windowed by day. So errorRate's denominator matches its + // numerator and the "API calls" tile matches the latency denominator. const apiCalls = filteredSessions.reduce((total, session) => total + session.apiCalls, 0); const toolCalls = filteredSessions.reduce((total, session) => total + sumRecordValues(session.toolCounts), 0); const errors = filteredSessions.reduce((total, session) => total + sumRecordValues(session.errorTypes), 0); @@ -97,15 +183,7 @@ function buildKpis( ); const projectCount = new Set(filteredSessions.map((session) => session.projectId)).size; - // Previous equal-length window for the trend chips. - const previousWindowEndMs = ANCHOR_MS - filters.rangeDays * MILLISECONDS_PER_DAY; - const previousWindowStartMs = previousWindowEndMs - filters.rangeDays * MILLISECONDS_PER_DAY; - const previousSessions = allSessions.filter((session) => { - if (!matchesDimensions(session, filters)) return false; - const startedMs = Date.parse(session.startedAt); - return startedMs >= previousWindowStartMs && startedMs < previousWindowEndMs; - }); - const previousTokens = visibleTotal(sumTokens(previousSessions)); + const previousTokens = previousWindowVisibleTokens(allSessions, filters, anchorMs); const currentTokens = visibleTotal(tokens); const tokensDeltaPct = previousTokens > 0 ? ((currentTokens - previousTokens) / previousTokens) * 100 : 0; @@ -126,6 +204,8 @@ function buildKpis( }; } +// ── usage over time (per day, windowed) ───────────────────────────────────── + interface MutableTimeBucket { bucketKey: string; label: string; @@ -136,30 +216,39 @@ interface MutableTimeBucket { calls: number; } -function weekStartKey(isoString: string): string { - const mondayMs = Date.parse(isoString) - isoWeekday(isoString) * MILLISECONDS_PER_DAY; +function weekStartKey(dayKeyValue: string): string { + const mondayMs = Date.parse(`${dayKeyValue}T00:00:00Z`) - isoWeekday(dayKeyValue) * MILLISECONDS_PER_DAY; return new Date(mondayMs).toISOString().slice(0, 10); } -function buildSeries(sessions: readonly StatsSession[], granularity: Granularity): readonly TimeBucket[] { +function buildSeries( + sessions: readonly StatsSession[], + granularity: Granularity, + cutoff: string | null, +): readonly TimeBucket[] { const bucketsByKey = new Map(); for (const session of sessions) { - const bucketKey = granularity === "day" ? dayKey(session.startedAt) : weekStartKey(session.startedAt); - const bucket = - bucketsByKey.get(bucketKey) ?? - { bucketKey, label: bucketKey, input: 0, output: 0, thinking: 0, total: 0, calls: 0 }; - bucket.input += session.tokens.input; - bucket.output += session.tokens.output; - bucket.thinking += session.tokens.thinking; - bucket.total += visibleTotal(session.tokens); - bucket.calls += session.apiCalls; - bucketsByKey.set(bucketKey, bucket); + for (const [day, dayBucket] of Object.entries(session.tokensByDay)) { + if (cutoff !== null && day < cutoff) continue; + const bucketKey = granularity === "day" ? day : weekStartKey(day); + const bucket = + bucketsByKey.get(bucketKey) ?? + { bucketKey, label: bucketKey, input: 0, output: 0, thinking: 0, total: 0, calls: 0 }; + bucket.input += dayBucket.input; + bucket.output += dayBucket.output; + bucket.thinking += dayBucket.thinking; + bucket.total += bucketVisible(dayBucket); + bucket.calls += dayBucket.apiCalls; + bucketsByKey.set(bucketKey, bucket); + } } return Array.from(bucketsByKey.values()).toSorted((first, second) => first.bucketKey.localeCompare(second.bucketKey), ); } +// ── dimension leaderboards (windowed tokens per group) ────────────────────── + interface GroupDescriptor { readonly groupKey: string; readonly label: string; @@ -175,13 +264,14 @@ interface MutableSlice { function groupSlices( sessions: readonly StatsSession[], + cutoff: string | null, describeGroup: (session: StatsSession) => GroupDescriptor, ): readonly NamedTokenSlice[] { const slicesByKey = new Map(); let grandTotal = 0; for (const session of sessions) { const descriptor = describeGroup(session); - const sessionTokens = visibleTotal(session.tokens); + const sessionTokens = bucketVisible(windowedTokens(session, cutoff)); grandTotal += sessionTokens; const slice = slicesByKey.get(descriptor.groupKey) ?? @@ -202,6 +292,8 @@ function groupSlices( .toSorted((first, second) => second.tokens - first.tokens); } +// ── tools / errors / approvals / latency (session-grain over the filtered set) ── + function buildTools(sessions: readonly StatsSession[]): readonly ToolStat[] { const callsByTool = new Map(); const failuresByTool = new Map(); @@ -276,6 +368,8 @@ function buildLatency(sessions: readonly StatsSession[]): readonly LatencyBucket return counts; } +// ── activity heatmap (from the per-weekday-hour slots) ────────────────────── + interface MutableHeatCell { weekday: number; hour: number; @@ -286,37 +380,44 @@ interface MutableHeatCell { function buildHeatmap(sessions: readonly StatsSession[]): readonly HeatCell[] { const cellsByKey = new Map(); for (const session of sessions) { - const weekday = isoWeekday(session.startedAt); - const hour = new Date(session.startedAt).getUTCHours(); - const cellKey = `${weekday}:${hour}`; - const cell = cellsByKey.get(cellKey) ?? { weekday, hour, tokens: 0, sessions: 0 }; - cell.tokens += visibleTotal(session.tokens); - cell.sessions += 1; - cellsByKey.set(cellKey, cell); + for (const [slot, tokens] of Object.entries(session.tokensByWeekdayHour)) { + const [weekdayPart, hourPart] = slot.split(":"); + const weekday = Number(weekdayPart); + const hour = Number(hourPart); + if (!Number.isInteger(weekday) || !Number.isInteger(hour)) continue; + const cell = cellsByKey.get(slot) ?? { weekday, hour, tokens: 0, sessions: 0 }; + cell.tokens += tokens; + cell.sessions += 1; + cellsByKey.set(slot, cell); + } } return Array.from(cellsByKey.values()); } +// ── assembly ──────────────────────────────────────────────────────────────── + export function buildView( allSessions: readonly StatsSession[], filters: StatsFilters, granularity: Granularity, + anchorMs: number, ): StatsView { - const filteredSessions = filterSessions(allSessions, filters); + const cutoff = cutoffDayKey(filters.rangeDays, anchorMs); + const filteredSessions = filterSessions(allSessions, filters, anchorMs); return { - kpis: buildKpis(allSessions, filteredSessions, filters), - series: buildSeries(filteredSessions, granularity), - composition: sumTokens(filteredSessions), - byModel: groupSlices(filteredSessions, (session) => ({ + kpis: buildKpis(allSessions, filteredSessions, filters, anchorMs, cutoff), + series: buildSeries(filteredSessions, granularity, cutoff), + composition: sumWindowedTokens(filteredSessions, cutoff), + byModel: groupSlices(filteredSessions, cutoff, (session) => ({ groupKey: session.model, - label: session.model.replace("qwen/", ""), + label: session.model, })), - byProject: groupSlices(filteredSessions, (session) => ({ + byProject: groupSlices(filteredSessions, cutoff, (session) => ({ groupKey: session.projectId, label: session.projectLabel, kind: session.projectKind, })), - byBranch: groupSlices(filteredSessions, (session) => ({ + byBranch: groupSlices(filteredSessions, cutoff, (session) => ({ groupKey: session.branch, label: session.branch, })), diff --git a/apps/web/src/ru-fork/stats/model/types.ts b/apps/web/src/ru-fork/stats/model/types.ts index d93b3c5a00d..b56c26e7d48 100644 --- a/apps/web/src/ru-fork/stats/model/types.ts +++ b/apps/web/src/ru-fork/stats/model/types.ts @@ -3,10 +3,10 @@ * * Everything the dashboard renders derives from a single atomic unit, a * {@link StatsSession} (one CLI chat). Filters narrow the session set; the - * selectors aggregate the survivors into the per-widget view models. This is - * fake/demo data — no server logic — but the shapes mirror what the real - * on-disk qwen telemetry (`qwen-code.api_response` / `.tool_call` / `.api_error`) - * would yield, so swapping in a real loader later is a drop-in. + * selectors aggregate the survivors into the per-widget view models. The session + * shape is owned by the server contract (`@t3tools/contracts`), computed from the + * on-disk qwen telemetry (`qwen-code.api_response` / `.tool_call` / `.api_error`); + * the view-model types below are the selectors' derived outputs. * * @module ru-fork/stats/model/types */ @@ -20,33 +20,29 @@ export interface TokenBreakdown { readonly cached: number; } -/** One CLI chat session — the atomic record all widgets aggregate from. */ -export interface StatsSession { - readonly sessionId: string; - readonly projectId: string; - readonly projectLabel: string; - readonly projectPath: string; - readonly projectKind: ProjectKind; - readonly branch: string; - readonly model: string; - /** ISO timestamp of the session start. */ - readonly startedAt: string; - readonly durationMs: number; - readonly turns: number; - readonly isBackground: boolean; - readonly apiCalls: number; - readonly tokens: TokenBreakdown; - readonly avgLatencyMs: number; - readonly maxLatencyMs: number; - /** functionName -> call count. */ - readonly toolCounts: Readonly>; - /** functionName -> failed call count (subset of toolCounts). */ - readonly toolFailures: Readonly>; - /** errorType -> count. */ - readonly errorTypes: Readonly>; - readonly autoAccepted: number; - readonly rejected: number; -} +// The session shape is owned by the server contract (one row per chat file). +// `tokens` is structurally the web `TokenBreakdown`; the extra `present`/`lastSeenAt` +// fields are ignored by the selectors. The local `ProjectKind` stays structurally +// equal to the contract's "real" | "temp". Imported (not just re-exported) so the +// derived view types below can reference it. +import type { StatsSession } from "@t3tools/contracts"; + +export type { StatsSession, StatsDayBucket, StatsCategory } from "@t3tools/contracts"; + +import type { StatsCategory } from "@t3tools/contracts"; + +/** Russian label for the session «Тип» column. */ +export const CATEGORY_LABEL: Record = { + dialog: "Диалог", + title: "Заголовок", + branch: "Ветка", + commit: "Коммит", + pr: "PR", + memory: "Память", + subagent: "Субагент", + compress: "Сжатие", + service: "Служебные", +}; /** Active dashboard filters (lives in the store). */ export interface StatsFilters { @@ -58,7 +54,8 @@ export interface StatsFilters { readonly traffic: TrafficFilter; } -export type RangeDays = 7 | 14 | 30 | 48; +/** Calendar-day windows. A number N = last N days (incl. today); "all" = no cutoff. */ +export type RangeDays = 1 | 7 | 14 | 30 | "all"; export type TrafficFilter = "all" | "turns" | "background"; export type Granularity = "day" | "week"; diff --git a/apps/web/src/ru-fork/stats/store.ts b/apps/web/src/ru-fork/stats/store.ts index 203cfb4d92f..f8b66c9e21e 100644 --- a/apps/web/src/ru-fork/stats/store.ts +++ b/apps/web/src/ru-fork/stats/store.ts @@ -1,21 +1,21 @@ /** * ru-fork: Analytics — UI state (zustand) + the derived-view hook. * - * Holds filters, chart granularity, the refresh cadence setting, and which - * drill-down (widget detail / session) is open. The actual numbers are derived - * by {@link useStatsView} from the fake session set for the current seed — - * "refresh" just bumps the seed + stamps a time, so it feels live, 0 server. + * Holds filters, granularity, the fetched sessions, load status, and which + * drill-down is open. Numbers are derived by {@link useStatsView} from the + * server-provided sessions for the current filters. The fetch itself lives in + * {@link useStatsData}; the ⟳ button bumps `refreshNonce` to force a refresh. * * @module ru-fork/stats/store */ import { useMemo } from "react"; import { create } from "zustand"; -import { BASE_SESSIONS, sessionsForSeed } from "./model/fakeData"; +import type { StatsSession } from "@t3tools/contracts"; + import { buildView } from "./model/selectors"; -import type { Granularity, StatsFilters, StatsSession, StatsView } from "./model/types"; +import type { Granularity, StatsFilters, StatsView } from "./model/types"; -/** Which expandable widget detail panel is open. */ export type WidgetId = | "usage" | "models" @@ -26,44 +26,34 @@ export type WidgetId = | "branches" | "composition"; -export interface RefreshIntervalOption { - readonly value: number; - readonly label: string; -} - -export const REFRESH_INTERVAL_OPTIONS: readonly RefreshIntervalOption[] = [ - { value: 5, label: "5 минут" }, - { value: 15, label: "15 минут" }, - { value: 30, label: "30 минут" }, - { value: 60, label: "1 час" }, - { value: 360, label: "6 часов" }, -]; +export type StatsStatus = "idle" | "loading" | "ready" | "error"; -const DEFAULT_FILTERS: StatsFilters = { +export const DEFAULT_FILTERS: StatsFilters = { rangeDays: 30, projectId: "all", model: "all", branch: "all", includeTemp: false, - traffic: "all", + traffic: "turns", }; interface StatsState { readonly filters: StatsFilters; readonly granularity: Granularity; - readonly refreshIntervalMin: number; - readonly seed: number; + readonly sessions: ReadonlyArray; + readonly status: StatsStatus; + readonly errorDetail: string | null; readonly lastRefreshedAtMs: number; - readonly isRefreshing: boolean; + readonly refreshNonce: number; readonly openWidget: WidgetId | null; readonly selectedSessionId: string | null; readonly setFilters: (patch: Partial) => void; readonly resetFilters: () => void; readonly setGranularity: (granularity: Granularity) => void; - readonly setRefreshIntervalMin: (refreshIntervalMin: number) => void; - readonly startRefresh: () => void; - readonly finishRefresh: () => void; + readonly setSnapshot: (sessions: ReadonlyArray, atMs: number) => void; + readonly setStatus: (status: StatsStatus, errorDetail?: string | null) => void; + readonly requestRefresh: () => void; readonly openWidgetDetail: (widget: WidgetId | null) => void; readonly selectSession: (sessionId: string | null) => void; } @@ -71,33 +61,40 @@ interface StatsState { export const useStatsStore = create()((set) => ({ filters: DEFAULT_FILTERS, granularity: "day", - refreshIntervalMin: 30, - seed: 0, - lastRefreshedAtMs: Date.now(), - isRefreshing: false, + sessions: [], + status: "idle", + errorDetail: null, + lastRefreshedAtMs: 0, + refreshNonce: 0, openWidget: null, selectedSessionId: null, setFilters: (patch) => set((state) => ({ filters: { ...state.filters, ...patch } })), resetFilters: () => set({ filters: DEFAULT_FILTERS }), setGranularity: (granularity) => set({ granularity }), - setRefreshIntervalMin: (refreshIntervalMin) => set({ refreshIntervalMin }), - startRefresh: () => set({ isRefreshing: true }), - finishRefresh: () => - set((state) => ({ isRefreshing: false, seed: state.seed + 1, lastRefreshedAtMs: Date.now() })), + // Replace the session set + clear any prior error. Used by both the instant read + // and a successful refresh. + setSnapshot: (sessions, atMs) => + set({ sessions, status: "ready", errorDetail: null, lastRefreshedAtMs: atMs }), + // Status-only update — never touches `sessions`, so a failed refresh keeps the + // last good data on screen (just flips status to "error" + a note). + setStatus: (status, errorDetail = null) => set({ status, errorDetail }), + // ⟳ button: bump the nonce so useStatsData runs a forced refresh. + requestRefresh: () => set((state) => ({ refreshNonce: state.refreshNonce + 1, status: "loading" })), openWidgetDetail: (openWidget) => set({ openWidget }), selectSession: (selectedSessionId) => set({ selectedSessionId }), })); -/** Derived dashboard view for the active filters/seed. Memoized per input. */ +/** Derived dashboard view for the active filters + fetched sessions. */ export function useStatsView(): StatsView { const filters = useStatsStore((state) => state.filters); const granularity = useStatsStore((state) => state.granularity); - const seed = useStatsStore((state) => state.seed); + const sessions = useStatsStore((state) => state.sessions); + const lastRefreshedAtMs = useStatsStore((state) => state.lastRefreshedAtMs); return useMemo(() => { - const sessions = seed === 0 ? BASE_SESSIONS : sessionsForSeed(seed); - return buildView(sessions, filters, granularity); - }, [filters, granularity, seed]); + const anchorMs = lastRefreshedAtMs > 0 ? lastRefreshedAtMs : Date.now(); + return buildView(sessions, filters, granularity, anchorMs); + }, [sessions, filters, granularity, lastRefreshedAtMs]); } export function findSessionById(view: StatsView, sessionId: string | null): StatsSession | null { diff --git a/apps/web/src/ru-fork/stats/useStatsData.ts b/apps/web/src/ru-fork/stats/useStatsData.ts new file mode 100644 index 00000000000..99cfd32979b --- /dev/null +++ b/apps/web/src/ru-fork/stats/useStatsData.ts @@ -0,0 +1,68 @@ +/** + * ru-fork: Analytics — keeps the store's session set in sync with the server. + * + * Two server ops, two triggers, nothing on a timer: + * - **Open the panel** → `getSnapshot` (instant read of the last-saved data) then + * `refresh` (scan disk, re-parse changed files, return current). A failed refresh + * keeps the read's data on screen — it never blanks. + * - **⟳ button** (bumps `refreshNonce`) → `refresh` only. + * + * @module ru-fork/stats/useStatsData + */ +import { useEffect, useRef } from "react"; + +import type { StatsSnapshot } from "@t3tools/contracts"; + +import { readEnvironmentConnection } from "~/environments/runtime/service"; +import { usePrimaryEnvironmentId } from "~/environments/primary/context"; +import { useStatsStore } from "./store"; + +export function useStatsData(): void { + const environmentId = usePrimaryEnvironmentId(); + const refreshNonce = useStatsStore((state) => state.refreshNonce); + const setSnapshot = useStatsStore((state) => state.setSnapshot); + const setStatus = useStatsStore((state) => state.setStatus); + const previousNonce = useRef(refreshNonce); + + useEffect(() => { + if (!environmentId) return; + const connection = readEnvironmentConnection(environmentId); + if (!connection) return; + let cancelled = false; + + const apply = (snapshot: StatsSnapshot) => { + if (cancelled) return; + setSnapshot(snapshot.sessions, Date.parse(snapshot.generatedAt)); + }; + const fail = (error: unknown) => { + if (cancelled) return; + // Status-only — the last good data stays on screen. + setStatus("error", error instanceof Error ? error.message : "Не удалось обновить статистику"); + }; + const runRefresh = () => connection.client.stats.refresh({}).then(apply).catch(fail); + + // A nonce change means the ⟳ button was pressed → refresh only. Otherwise this + // is an open (mount or primary-environment change) → instant read, then refresh. + const forced = refreshNonce !== previousNonce.current; + previousNonce.current = refreshNonce; + + setStatus("loading"); + if (forced) { + void runRefresh(); + } else { + connection.client.stats + .getSnapshot({}) + .then(apply) + .catch(() => { + // An empty/failed read is fine — the refresh below populates or reports. + }) + .finally(() => { + if (!cancelled) void runRefresh(); + }); + } + + return () => { + cancelled = true; + }; + }, [environmentId, refreshNonce, setSnapshot, setStatus]); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 91c260c6a89..cfea06311ea 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,6 +18,8 @@ export * from "./ru-fork/terminalErrors.ts"; export * from "./ru-fork/mcp.ts"; // ru-fork: advanced chat mode — normalized qwen transcript contract. export * from "./ru-fork/transcript.ts"; +// ru-fork: stats (analytics) — per-session usage telemetry contract. +export * from "./ru-fork/stats.ts"; export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7c93dbf19fc..eb63856e99f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -99,6 +99,13 @@ import { TranscriptRpcSchemas, TranscriptSubscribeError, } from "./ru-fork/transcript.ts"; +// ru-fork: stats (analytics) — pure-read snapshot + disk refresh. +import { + STATS_GET_SNAPSHOT_METHOD, + STATS_REFRESH_METHOD, + StatsError, + StatsSnapshot, +} from "./ru-fork/stats.ts"; import { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -540,6 +547,19 @@ export const WsSubscribeMcpRuntimeRpc = Rpc.make(WS_METHODS.subscribeMcpRuntime, stream: true, }); +// ru-fork: stats — pure DB read (no disk scan); the panel's instant safety-net load. +export const WsStatsGetSnapshotRpc = Rpc.make(STATS_GET_SNAPSHOT_METHOD, { + payload: Schema.Struct({}), + success: StatsSnapshot, + error: StatsError, +}); +// ru-fork: stats — scan disk, re-parse changed files, save, return (open + ⟳ button). +export const WsStatsRefreshRpc = Rpc.make(STATS_REFRESH_METHOD, { + payload: Schema.Struct({}), + success: StatsSnapshot, + error: StatsError, +}); + export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { payload: Schema.Struct({}), success: ServerLifecycleStreamEvent, @@ -610,4 +630,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeMcpRuntimeRpc, // ru-fork: advanced chat mode. WsOrchestrationSubscribeTranscriptRpc, + // ru-fork: stats (analytics). + WsStatsGetSnapshotRpc, + WsStatsRefreshRpc, ); diff --git a/packages/contracts/src/ru-fork/stats.ts b/packages/contracts/src/ru-fork/stats.ts new file mode 100644 index 00000000000..d4dd99253cd --- /dev/null +++ b/packages/contracts/src/ru-fork/stats.ts @@ -0,0 +1,106 @@ +// ru-fork: contract for the Stats (Analytics) feature — a per-session view of +// qwen's on-disk usage telemetry (read-only). The server's StatsScanner computes +// one StatsSession per chat file and returns them in a StatsSnapshot; the web +// aggregates them into the dashboard. Kept under ru-fork/ so upstream re-syncs +// never collide. Shapes mirror apps/web/src/ru-fork/stats/model/types.ts so the +// existing UI selectors consume the server data unchanged. +import * as Schema from "effect/Schema"; + +import { IsoDateTime } from "../baseSchemas.ts"; + +/** WS method names. Defined here (not in orchestration.ts) to avoid touching upstream. */ +// getSnapshot = pure DB read (never scans disk); refresh = scan + parse changed + +// save + return (the only command that touches the transcripts). +export const STATS_GET_SNAPSHOT_METHOD = "stats.getSnapshot" as const; +export const STATS_REFRESH_METHOD = "stats.refresh" as const; + +export const StatsProjectKind = Schema.Literals(["real", "temp"]); +export type StatsProjectKind = typeof StatsProjectKind.Type; + +/** What a chat file actually is. Only "dialog" is a real interactive conversation; + * the rest are one-shot/automatic calls (our text-gen, or qwen-internal). */ +export const StatsCategory = Schema.Literals([ + "dialog", + "title", + "branch", + "commit", + "pr", + "memory", + "subagent", + "compress", + "service", +]); +export type StatsCategory = typeof StatsCategory.Type; + +export const StatsTokenBreakdown = Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + thinking: Schema.Number, + cached: Schema.Number, +}); +export type StatsTokenBreakdown = typeof StatsTokenBreakdown.Type; + +/** A map "name -> count" carried per session (tool calls, failures, error types). */ +export const StatsCountMap = Schema.Record(Schema.String, Schema.Number); +export type StatsCountMap = typeof StatsCountMap.Type; + +/** Per-day slice of a session's usage (tokens + api calls that fell on that day). */ +export const StatsDayBucket = Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + thinking: Schema.Number, + cached: Schema.Number, + apiCalls: Schema.Number, +}); +export type StatsDayBucket = typeof StatsDayBucket.Type; + +/** One CLI chat file, fully reduced to the numbers the dashboard needs. */ +export const StatsSession = Schema.Struct({ + sessionId: Schema.String, + projectId: Schema.String, + projectLabel: Schema.String, + projectPath: Schema.String, + projectKind: StatsProjectKind, + branch: Schema.String, + model: Schema.String, + startedAt: Schema.String, // ISO of the first event + durationMs: Schema.Number, + turns: Schema.Number, + category: StatsCategory, + isBackground: Schema.Boolean, + apiCalls: Schema.Number, + tokens: StatsTokenBreakdown, + avgLatencyMs: Schema.Number, + maxLatencyMs: Schema.Number, + toolCounts: StatsCountMap, + toolFailures: StatsCountMap, + errorTypes: StatsCountMap, + autoAccepted: Schema.Number, + rejected: Schema.Number, + /** Usage attributed to the day each event happened (key "YYYY-MM-DD", UTC). */ + tokensByDay: Schema.Record(Schema.String, StatsDayBucket), + /** Visible tokens per weekday+hour slot (key "weekday:hour", Mon=0, UTC) — heatmap. */ + tokensByWeekdayHour: Schema.Record(Schema.String, Schema.Number), + /** false ⇒ source file deleted but stats retained. */ + present: Schema.Boolean, + /** ISO when this file was last seen on disk. */ + lastSeenAt: IsoDateTime, +}); +export type StatsSession = typeof StatsSession.Type; + +export const StatsSnapshot = Schema.Struct({ + sessions: Schema.Array(StatsSession), + generatedAt: IsoDateTime, + scannedFiles: Schema.Number, + parsedFiles: Schema.Number, +}); +export type StatsSnapshot = typeof StatsSnapshot.Type; + +export class StatsError extends Schema.TaggedErrorClass()("StatsError", { + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Stats error: ${this.detail}`; + } +} diff --git a/stats/audit.md b/stats/audit.md new file mode 100644 index 00000000000..55dc2580175 --- /dev/null +++ b/stats/audit.md @@ -0,0 +1,194 @@ +# Stats (Analytics) — Code Quality Audit + +Independent audit of the current implementation (uncommitted, worktree `stats-logic`). +Read every changed/new file in its final state. No code was changed for this audit. +Honest assessment, issues ranked by severity, with file references. + +--- + +## Verdict + +**Yes — this is senior-level, production-ready code _for its stated scope_** (a +single-user / single-machine personal analytics panel). It is well-structured, idiomatic +Effect-TS, strongly typed (no `any`/`unknown`/casts in our surface), resilient to bad +input, and the server is genuinely well-tested (52 tests). The architecture (per-file +session as the atom, incremental durable cache, CQRS read/refresh split, client-side +aggregation) is sound and clearly reasoned. + +It is **not** "ship to thousands of users" hardened, and it shouldn't pretend to be — +there are a handful of deliberate trade-offs (documented below) plus three genuine nits +worth tightening. None are blockers for the intended use. + +Overall grade: **A− / strong senior.** The gaps are in *test reach on the client* and a +couple of *single-source-of-truth* shortcuts, not in the core engineering. + +--- + +## Strengths (what's done right) + +- **Pure/effectful separation.** `telemetry`, `aggregate`, `paths`, `serviceSignatures`, + and the entire web `selectors` are pure and independently testable; only `StatsScanner` + and the repo are effectful. This is the single biggest reason the code is trustworthy. +- **Idiomatic Effect + persistence.** `Context.Service` + `Layer.effect`, `SqlSchema` + repos, tagged `StatsError`, `Effect.option`/`orElseSucceed` for "skip on failure", + `DateTime.now` idiom, `sql.in` with empty-array guards. Matches the MCP feature's + conventions exactly (same wiring seams). +- **Error resilience is real, not decorative.** A malformed JSONL line, a locked file, a + missing dir — each is skipped/logged, never poisons the snapshot or fails the refresh. + On the client, a failed `refresh` keeps the last-good data on screen (`setStatus` never + touches `sessions`). The read/refresh split means a refresh error can't blank the panel. +- **Type discipline.** Structural guards (`isObject`/`asString`/`asNumber`) instead of + casts; the contract is the single source of truth and the web re-exports it; the + category union is shared so server and client can't drift on the enum. +- **Correct, non-trivial modeling.** Incremental cache (mtime+size change detection), + retain-after-delete (`present=0`), ghost detection + purge, faceted filter dropdowns, + and genuinely correct per-day token attribution (`tokensByDay`) so a multi-day session + contributes to each day — a real improvement over single-timestamp pinning. +- **Server test coverage is strong.** Every telemetry/aggregate branch, every category + path, scanner integration (incremental, retain, ghost, empty root), and cache + round-trip — with proper isolation (fresh `:memory:` DB per test, per-test scoped temp + dir via `QWEN_RUNTIME_DIR`). +- **Security is clean.** Read-only w.r.t. transcripts; only writes its own cache table. + Parameterized SQL throughout; `JSON.parse` wrapped; no injection or path-traversal + surface; tilde expansion is bounded to `$HOME`. + +--- + +## Issues + +### High — none +No correctness-breaking or security defects found. + +### Medium + +**M1. Mixed metric grain can mislead on boundary sessions.** +`selectors.ts::buildKpis` windows token totals **and** `apiCalls` (from `tokensByDay`), +but `errors`/`toolCalls` are whole-session and `avgLatencyMs` is weighted by +*whole-session* `apiCalls`. So for a session straddling the window edge: +- `errorRatePct = errors(whole) / apiCalls(windowed)` — mixed basis; +- the displayed "API calls" tile (windowed) can disagree with the latency denominator + (whole-session). +Impact is small (only boundary-straddling sessions, rare for sub-day windows) and it's a +deliberate choice (tools/errors have no time axis), but it's a real inconsistency a +reviewer should know. If it ever matters, drive everything time-based from `tokensByDay`. + +**M2. `SERVICE_SIGNATURES` hand-mirrored, not imported — drift risk. ✅ FIXED.** +*Was:* `serviceSignatures.ts` copied substrings of the prompts in `textGeneration/*` +with a "keep in sync" comment — not a true single source, so a reworded prompt would +silently drop title/branch/commit/PR sessions into `service`/Фон. +*Now:* the instruction strings live in a single leaf module +`apps/server/src/textGeneration/instructions.ts`, **used** by `CliTextGeneration.ts` / +`TextGenerationPrompts.ts` and **imported** by `serviceSignatures.ts`; a drift-guard +test (`serviceSignatures.test.ts`) asserts every marker is a substring of its +instruction, so any future reword fails a test instead of misclassifying silently. + +**M3. Title detection is an inherent content heuristic.** +Even done perfectly, distinguishing our `title`/`branch`/`commit`/`pr` calls from a real +`qwen -p` is *only* possible by content — the `prompt_id` and input size are identical +(verified). So `service` is a true catch-all that also swallows real `qwen -p` prompts +into Фон. This is correct given the data (documented), but it means hand-run one-shots +are hidden by default and a future qwen-internal call type would land in `service`. + +**M4. Client selectors are unit-untested.** +`apps/web` has no test target (repo-wide constraint), so `selectors.ts` — the most +complex client logic (windowing, faceting, all the `buildX`) — has **zero** tests. The +server compute is well covered, but the client recomputation of windows/series/heatmap is +only protected by typecheck. For "senior production," this is the most notable gap; it's a +repo constraint, not negligence, but worth flagging loudly. + +**M5. UTC-day window semantics. ✅ FIXED (same-machine).** +*Was:* "Сегодня"/"7 дней" were **UTC** calendar days, so a user off UTC saw late-evening +local activity land in the next day's bucket — not "local today". The session-time +*display* (`formatDateTime`) was likewise UTC. +*Now:* day/hour are bucketed in the **machine-local** zone — `aggregateSession` takes the +zone as a parameter (pure, no ambient read), the scanner passes +`Intl.DateTimeFormat().resolvedOptions().timeZone`, the client computes the browser-local +day, and `formatDateTime` renders the local clock. Same machine ⇒ same zone ⇒ "Сегодня" += your real local today, and session times show your local clock. +*Remaining (documented) limitation:* a **remote** server in a different timezone keys by +the *server's* local day — true viewer-local-today with a remote server would need +hour-grain buckets folded client-side (the ~1% case; intentionally not done). + +### Low + +- **L1. Whole-table reads / no pagination.** `getSnapshot` and `refresh` both `listAll()` + the entire cache (refresh does it twice) and JSON-decode every `session_json`. Fine for + thousands of sessions; a clear O(N) ceiling at very large histories. No streaming/paging. +- **L2. Ghosts re-read every refresh.** Ghost files are never cached, so each `refresh` + re-reads+re-parses all of them (O(ghosts) wasted I/O). Small, but unbounded by design. +- **L3. No per-file read timeout.** A genuinely hung read (stalled network mount) could + stall a refresh until the WS times out. The user explicitly declined a timeout; local + disk won't hit this. +- **L4. `subagent` detection is loose.** `classifyCategory` treats any non-dialog, + non-side-query, non-compress `prompt_id` containing `#` as `subagent`. Real subagent ids + are the only `#`-bearing ones in practice, but it's a structural assumption, not a guard. +- **L5. `process.env` read inside the engine.** `StatsScanner.listDiskFiles` calls + `resolveProjectsRoot({env: process.env, …})`, coupling to a global. Tests must mutate + `process.env` (they do, with `beforeEach` cleanup). Injecting env via `ServerConfig` + would be cleaner and remove the test-global dance. +- **L6. `anchorMs` = last-refresh time, not real now.** If the panel sits open without a + refresh, "Сегодня" is anchored to the last refresh. Negligible given fetch-on-open. +- **L7. Heatmap can't be date-window-scoped.** `tokensByWeekdayHour` collapses dates, so + for a 7-day window the heatmap reflects the filtered sessions' whole weekday/hour + pattern, not just the in-window dates. Acceptable (it's a "when do I work" view). + +### Nits + +- **N1. Duplicated `DEFAULT_FILTERS`.** Defined in both `store.ts` and + `StatsFilterBar.tsx`. They were kept in sync this round (traffic → "turns" in both), but + it's a real drift hazard — extract one shared const. +- **N2. `model/types.ts` has `import` statements mid-file** (lines 28, 32, after exports). + Works (hoisted), but unconventional; move imports to the top. +- **N3. `StatsFilterBar` runs `filterSessions` 3× per render** (one per faceted dropdown, + each a full pass), plus the main view's pass = 4 passes over all sessions. Memoized on + `[sessions, filters, anchorMs]`, so only on change, but not free at large N. +- **N4. Dead-ish fallback.** `aggregate.ts` still has `startedAt = … ?? nowIso` for the + empty-events case, now unreachable in production (ghosts are skipped before aggregate); + harmless but slightly misleading. The empty-file unit test documents it. +- **N5. Service markers under-tested.** `aggregate.test.ts` asserts the `title` marker + path but not `branch`/`commit`/`pr` individually. + +--- + +## Per-area summary + +| Area | Assessment | +|---|---| +| Contracts | Clean, complete, single-source-of-truth, shared enum. | +| Telemetry parsing | Excellent — pure, guarded, never throws, fully branch-tested. | +| Aggregation | Strong; one heuristic (categories) that's inherently content-based (M3) + a mirrored marker list (M2). | +| Scanner / persistence | Solid; incremental, durable, resilient; whole-table reads are the only scale concern (L1). | +| Wiring | Minimal, idiomatic, matches MCP seam set exactly. | +| Web data layer | Good; read/refresh + status handling is thoughtful; selectors untested (M4). | +| Web UI | Unchanged widgets; data wiring is clean; faceted filters are a nice touch. | +| Tests | Server: strong. Client: none (repo constraint). | +| Security | Clean. | + +--- + +## Recommendations (in priority order) + +1. ✅ **(M2/N5) — DONE.** Instruction constants centralized in + `textGeneration/instructions.ts`, imported by `serviceSignatures.ts`, with a + drift-guard test. +2. **(N1)** Extract one shared `DEFAULT_FILTERS` (still duplicated in `store.ts` + + `StatsFilterBar.tsx`). +3. **(M1)** Decide the metric grain explicitly — either window everything from + `tokensByDay`, or document the KPI denominators as whole-session; right now it's mixed. +4. **(M4)** If/when the repo grows a web test target, unit-test `selectors.ts` + (windowing, faceting, series/heatmap) — it's the highest-value untested code. +5. ✅ **(M5) — DONE (same-machine).** Local-zone bucketing + local session-time display; + remote-different-TZ remains the documented ~1% limitation. +6. **(L1)** Revisit whole-table reads only if histories reach tens of thousands of files. + +--- + +## Bottom line + +This is the work of a careful senior: clean separation, real resilience, strong typing, +good server tests, and honest handling of a genuinely messy data source (qwen's +undifferentiated one-shot calls). **M2 (single-source instructions) and M5 (local-day +windowing + local time display) are now fixed.** The remaining open items are documented +trade-offs and two nits (N1 duplicated `DEFAULT_FILTERS`, M1 mixed metric grain) — not +correctness or safety defects. Address N1 and M1 and it's unambiguously production-grade +for this feature's scope. diff --git a/stats/backend.md b/stats/backend.md new file mode 100644 index 00000000000..cac6163ba45 --- /dev/null +++ b/stats/backend.md @@ -0,0 +1,264 @@ +# Stats (Analytics) — Backend implementation + +Full per-file detail of the server side, generated from the current code. Pure modules +(no I/O, no Effect) are unit-tested in isolation; effectful modules are integration-tested +with a memory DB + temp filesystem. 52 tests total (see §9). + +--- + +## 1. Contract — `packages/contracts/src/ru-fork/stats.ts` + +The single source of truth shared by server and web (`@t3tools/contracts`). + +**WS method names** (string literals, not added to upstream `WS_METHODS`): +```ts +STATS_GET_SNAPSHOT_METHOD = "stats.getSnapshot" +STATS_REFRESH_METHOD = "stats.refresh" +``` + +**Schemas / types:** +- `StatsProjectKind` = `Literals(["real","temp"])`. +- `StatsCategory` = `Literals(["dialog","title","branch","commit","pr","memory","subagent","compress","service"])`. +- `StatsTokenBreakdown` = `{input,output,thinking,cached: Number}`. +- `StatsCountMap` = `Record(String, Number)` (tool counts, failures, error types). +- `StatsDayBucket` = `{input,output,thinking,cached,apiCalls: Number}`. +- `StatsSession` = the per-file row (every field below). +- `StatsSnapshot` = `{sessions: StatsSession[], generatedAt: IsoDateTime, scannedFiles, parsedFiles}`. +- `StatsError` = `TaggedErrorClass` with `{detail, cause?: Defect}` and a `message` getter. + +**`StatsSession` fields:** `sessionId, projectId, projectLabel, projectPath, +projectKind, branch, model, startedAt, durationMs, turns, category, isBackground, +apiCalls, tokens, avgLatencyMs, maxLatencyMs, toolCounts, toolFailures, errorTypes, +autoAccepted, rejected, tokensByDay, tokensByWeekdayHour, present, lastSeenAt`. + +**Wiring (`contracts/src/index.ts`, `rpc.ts`):** +- `index.ts`: `export * from "./ru-fork/stats.ts"`. +- `rpc.ts`: imports the two method consts + `StatsError`/`StatsSnapshot`; defines + `WsStatsGetSnapshotRpc` and `WsStatsRefreshRpc` (both `payload: Struct({})`, + `success: StatsSnapshot`, `error: StatsError`); registers both in `WsRpcGroup`. + +--- + +## 2. `paths.ts` — projects-root resolution (pure) + +`// @effect-diagnostics nodeBuiltinImport:off` — uses `node:path`/`node:os` directly. + +- `resolveProjectsRoot({env, cliConfigDir})` → `/projects` where `base` = + `expandBaseDir(QWEN_RUNTIME_DIR)` if set & non-blank, else `cliConfigDir`. +- `expandBaseDir(dir)` → tilde (`~`, `~/x`) expansion against `os.homedir()`; + relative paths resolved against home; absolute returned as-is. +- `chatsDirFor(root, projectDir)` → `//chats`. +- `isTempCwd(cwd)` → true for `/var/folders/`, `/private/var/folders/`, `/tmp/`, + `/private/tmp/` prefixes (sandbox classification). +- `projectLabelFor(cwd)` → last non-empty path segment (fallback: the cwd itself). + +Tested by `paths.test.ts` (env override, tilde, fallback, blank env, temp prefixes, +labels) — 6 tests. + +--- + +## 3. `telemetry.ts` — JSONL extraction (pure, no throws) + +`extractFileTelemetry(text): FileTelemetry` walks the file line-by-line: +- `JSON.parse` each non-blank line inside try/catch — **a malformed line is skipped**, + never poisons the file. +- Structural guards only (`isObject`/`asString`/`asNumber`/`asBoolean`), no casts. +- Captures `cwd` (first non-empty), `branch` (**last** non-empty `gitBranch`), + `sessionId` (first), and `firstUserText` (first `type:"user"` record's message). +- `userMessageText(message)` handles both `string` and `{parts:[{text}]}` shapes + (joins part texts with `\n`). +- `extractUiEvent` maps the three `event.name`s to discriminated `TelemetryEvent`s: + - `api_response` → tokens default to `0` when missing; `model`/`promptId` optional. + - `tool_call` → dropped if `function_name` missing; `success` defaults `true`; + `decision` only `auto_accept`/`reject`, else `undefined`. + - `api_error` → `error_type` defaults `"UnknownError"`. + - any event with no `event.timestamp` → dropped. + +`FileTelemetry` = `{events, cwd, branch, sessionId, firstUserText}`. + +Tested by `telemetry.test.ts` (every branch, malformed/blank handling, first-user-text +string/parts shapes, empty text) — 11 tests. + +--- + +## 4. `instructions.ts` + `serviceSignatures.ts` — single-source service markers + +`apps/server/src/textGeneration/instructions.ts` is a pure leaf module exporting the 4 +instruction strings our text-generation calls send to qwen +(`THREAD_TITLE_INSTRUCTION`, `BRANCH_NAME_INSTRUCTION`, `COMMIT_MESSAGE_INSTRUCTION`, +`PR_CONTENT_INSTRUCTION`). `CliTextGeneration.ts` (title, branch) and +`TextGenerationPrompts.ts` (commit, pr) **use** these constants — they are the source. + +`serviceSignatures.ts` **imports** them; each `ServiceSignature` carries +`{category, instruction, marker}` where `marker` is a distinctive substring of the +imported `instruction` (substring, not full string, so it stays robust to historical +wording variants): +```ts +SERVICE_SIGNATURES = [ + { title, THREAD_TITLE_INSTRUCTION, "titles for coding conversations" }, + { branch, BRANCH_NAME_INSTRUCTION, "concise git branch name" }, + { commit, COMMIT_MESSAGE_INSTRUCTION, "git commit messages" }, + { pr, PR_CONTENT_INSTRUCTION, "pull request content" }, +] +``` +`serviceSignatures.test.ts` asserts `instruction.includes(marker)` for each → if a +prompt is reworded past its marker, a test fails loudly instead of silently +misclassifying. This is the true single-source fix (no hand-mirrored copy). + +--- + +## 5. `aggregate.ts` — `FileTelemetry` → `StatsSession` (pure) + +`aggregateSession({telemetry, projectDir, fileSessionId, nowIso}): StatsSession`. + +**Constants:** `SIDE_QUERY_PREFIX="side-query:"`, `COMPRESS_PREFIX="compress-"`, +`REAL_TURN_MARKER="########"`. + +`aggregateSession` takes a `timeZone: string` (IANA zone) — it's a **pure function of the +zone**, no ambient-TZ read. Day/hour are bucketed in that zone. + +**Helpers:** +- `dayOf(ts, timeZone)` = local "YYYY-MM-DD" via a memoized `Intl.DateTimeFormat("en-CA", + {timeZone, …})` (en-CA renders ISO order); returns `""` for an unparseable timestamp. + Uses `Intl`, **not** `new Date()` (banned by the effect diagnostic on the server). +- `hourOf(ts, timeZone)` = local hour 0–23 via a memoized `Intl` `hourCycle:"h23"` + formatter (0 for an unparseable timestamp). +- `weekdayOfDayKey(dayKey)` = weekday (Mon=0) of a `"YYYY-MM-DD"` key — calendar weekday, + TZ-independent, from epoch-day math (1970-01-01 = Thursday) to avoid `new Date()`. + The heatmap slot is `` `${weekdayOfDayKey(day)}:${hourOf(ts, timeZone)}` ``. +- An event with an unparseable timestamp is skipped from the time buckets (no crash). +- `countTurns(events)` = distinct `api_response` `prompt_id`s containing `########`. +- `classifyCategory(telemetry)` = the §5 ordering of the architecture doc: + dialog (`########`) → memory (`side-query:`) → compress (`compress-`) → + subagent (`#`) → service-signature content match → `service`. +- `dominant(counts, fallback)` = max-count key (the dominant model). + +**Reduction (single passes over events):** +- token sums + latency (`latencySum`, `maxLatencyMs`) + `modelCounts` over `api_response`. +- `toolCounts`/`toolFailures` + `autoAccepted`/`rejected` over `tool_call`. +- `errorTypes` over `api_error`. +- `tokensByDay` (every event marks an activity day; `api_response` adds tokens+calls) and + `tokensByWeekdayHour` (`api_response` visible tokens per slot). +- time span: `startedAt = sorted timestamps[0] ?? nowIso`, `durationMs = max(0, end-start)`. +- `category = classifyCategory(...)`, `isBackground = category !== "dialog"`. + +**Output:** the full `StatsSession`; `model = dominant(modelCounts, "")`, +`avgLatencyMs = latencySum/apiCalls` (0 when none), `present:true`, +`lastSeenAt = IsoDateTime.make(nowIso)`. + +Tested by `aggregate.test.ts` (token sums, latency, turns, background, tools/approvals, +errors, time span, day/weekday-hour buckets, all category branches, temp vs real, empty +file) — 22 tests. + +--- + +## 6. `StatsScanner.ts` — the engine (effectful) + +`StatsScanner` = `Context.Service` with `{getSnapshot, refresh}`; `StatsScannerLive = +Layer.effect`. Dependencies resolved from the runtime graph: `FileSystem`, `Path`, +`ServerConfig`, `StatsFileCacheRepository`. + +**Helpers inside the layer:** +- `timeZone` = `Intl.DateTimeFormat().resolvedOptions().timeZone` (the machine-local IANA + zone, read once), passed into every `aggregateSession({…, timeZone})` so day/hour + buckets use the local day. +- `nowIso()` = `Effect.map(DateTime.now, DateTime.formatIso)` (codebase idiom). +- `listDiskFiles()` — walks `projectsRoot/*/chats/*.jsonl`; every FS op wrapped in + `orElseSucceed`/`Effect.option` so a missing/failed dir or stat skips rather than + fails. Per file collects `{filePath, projectDir, fileSessionId, mtimeMs, sizeBytes}`. + `mtimeMs` from `Option.map(info.mtime, d=>d.getTime())` defaulting to `0`. +- `readText(file)` — `readFileString`; on failure logs `error` ("skipped unreadable + file") and returns `Option.none` → caller keeps any prior row. +- `parseFile(file, when): ParseOutcome` — `{kind:"skip"}` (unreadable), + `{kind:"ghost"}` (no `api_response`), or `{kind:"session", session}`. +- `reconcilePresence(row)` / `buildSnapshot(rows, when, scanned, parsed)` — present + rows return their session as-is; absent rows get `present:false` + `lastSeenAt` + reconciled from the column. + +**`getSnapshot()`** — pure read: `nowIso` → `cache.listAll()` → `buildSnapshot(rows, +when, 0, 0)`. Wrapped in `tapError(logError)` + `mapError(StatsError)`. Never scans. + +**`refresh()`** — `logDebug("refresh start")`; `listDiskFiles` + `cache.listAll`; +for each **changed** file (`existing` absent / not-present / mtime|size mismatch): +parse → skip (keep prior) | ghost (collect path) | upsert; then +`cache.removeByPaths(ghostPaths)` (purge stored ghosts); `cache.markAbsent(vanished)` +(retain-after-delete); `cache.listAll()` again → `buildSnapshot(..., diskFiles.length, +parsedFiles)`; `logDebug("refresh done", {scanned, parsed, ghosts, retained, total})`. +Wrapped in `tapError(logError)` + `mapError(StatsError)`. + +**Logging:** start/end at `debug`; per-file read failures and the whole-refresh / +read failures at `error` (always in the server console) — per the repo's error-or-debug +rule. + +Tested by `statsScanner.test.ts` (parse-all, pure-read-doesn't-scan, incremental reuse, +changed re-parse, new dir pickup, retain-after-delete, ghost skip incl. tool/error-only, +empty root) — 9 tests, with a per-test scoped temp dir pointed at via `QWEN_RUNTIME_DIR` +and a fresh memory DB. + +--- + +## 7. Persistence + +### `Migrations/032_Stats.ts` +`CREATE TABLE IF NOT EXISTS stats_file_cache (file_path TEXT PRIMARY KEY, mtime_ms +INTEGER, size_bytes INTEGER, present INTEGER DEFAULT 1, last_seen_at TEXT, session_json +TEXT)`. Registered in `Migrations.ts` as `[32, "Stats", Migration0032]`. + +### `Services/StatsFileCache.ts` +- `StatsFileCacheRow` schema = `{filePath, mtimeMs, sizeBytes, present:boolean, + lastSeenAt, session: StatsSession}`. +- `MarkAbsentInput` = `{filePaths: string[], lastSeenAt}`. +- `StatsFileCacheRepository` Service with `{listAll, upsert, markAbsent, removeByPaths}`, + all returning `Effect<_, ProjectionRepositoryError>`. + +### `Layers/ProjectionStatsFileCache.ts` +- `StatsFileCacheDbRow` = the row schema with `present: NonNegativeInt` (0/1 INTEGER → + boolean by `rowToCacheRow`, the McpCatalog pattern) and `session: + fromJsonString(StatsSession)`. +- `listRows` (`SqlSchema.findAll`, `ORDER BY file_path ASC`), `upsertRow` + (`INSERT … ON CONFLICT(file_path) DO UPDATE`), `markAbsentRows` + (`UPDATE … SET present=0 … WHERE file_path IN ${sql.in(...)}`). +- `removeByPaths` uses a raw `DELETE … WHERE file_path IN ${sql.in(...)}`.pipe(asVoid). +- All three list-bearing ops guard the **empty array** (an empty `sql.in` is invalid), + returning `Effect.void`. Errors mapped via `toPersistenceSqlError("StatsFileCache.X")`. + +Tested by `StatsFileCache.test.ts` (round-trip decode, overwrite, markAbsent retain, +empty no-ops, fresh-DB empty, removeByPaths purge + empty no-op) — 7 tests, fresh +`:memory:` DB **per test** (provided inside each `it.effect`, not a memoized `it.layer`, +because `listAll` reads the whole table). + +--- + +## 8. Layer composition + wiring + +- `StatsLayers.ts`: `StatsLive = StatsScannerLive.pipe(Layer.provide(StatsFileCacheRepositoryLive))`. + The repo's `SqlClient` and the scanner's `FileSystem`/`Path`/`ServerConfig` are + satisfied by the shared runtime graph where `StatsLive` is `provideMerge`d. +- `server.ts`: `import { StatsLive }`; `Layer.provideMerge(StatsLive)` immediately after + `McpRuntimeServicesLive` in `RuntimeCoreDependenciesLive`. +- `ws.ts`: imports the two method consts + `StatsScanner`; `const statsScanner = yield* + StatsScanner` in `makeWsRpcLayer`; two handlers keyed by the method literals, each + `observeRpcEffect(method, statsScanner.getSnapshot()/refresh(), {"rpc.aggregate":"stats"})`. + +This is the exact seam set MCP uses (DB-backed runtime service, registered at the same +line, yielded in the same ws scope). + +--- + +## 9. Test inventory (server) + +| File | Tests | Covers | +|---|---|---| +| `telemetry.test.ts` | 11 | every event branch, malformed/blank skip, dimension capture, firstUserText | +| `aggregate.test.ts` | 22 | sums, latency, turns, background, tools, errors, time, day/hour buckets (`timeZone:"UTC"`), all categories, temp/real, empty | +| `paths.test.ts` | 6 | env override, tilde, fallback, blank env, temp prefixes, labels | +| `statsScanner.test.ts` | 9 | read vs refresh, incremental, retain-after-delete, ghost skip, empty root | +| `StatsFileCache.test.ts` | 7 | round-trip, overwrite, markAbsent, removeByPaths, empty no-ops | +| `serviceSignatures.test.ts` | 2 | drift guard (marker ⊂ instruction), category coverage | + +**54 server tests.** Verified state: `pnpm typecheck` 14/14, `pnpm lint` 0/0, +`pnpm test:fast` 1079 pass / 57 skip (only the pre-existing `bin.test.ts` qwen-absent +baseline fails, unrelated). + +The web has **no test target** (repo constraint), so the client selectors are not +unit-tested — see `audit.md`. diff --git a/stats/frontend.md b/stats/frontend.md new file mode 100644 index 00000000000..c5b1ea8ad45 --- /dev/null +++ b/stats/frontend.md @@ -0,0 +1,210 @@ +# Stats (Analytics) — Frontend implementation + +Full per-file detail of the web side (`apps/web/src/ru-fork/stats/` + the RPC seam), +generated from the current code. The widgets (KPI strip, charts, sheets) are unchanged +from the original UI; the data layer (rpc, store, hook, selectors, filters) is what wires +them to the live server. + +Stack: React + zustand + Recharts (via the basecn `chart` wrapper) + Base UI + Tailwind v4. +All numbers come from the server `StatsSession[]`; the client does the aggregation and +filtering so the dashboard re-renders instantly with no round-trips. + +--- + +## 1. RPC seam — `apps/web/src/rpc/wsRpcClient.ts` + +Adds a `stats` namespace to the sole `WsRpcClient` implementation: +```ts +readonly stats: { + readonly getSnapshot: RpcUnaryMethod; + readonly refresh: RpcUnaryMethod; +}; +// impl: +stats: { + getSnapshot: (input) => transport.request((c) => c[STATS_GET_SNAPSHOT_METHOD](input)), + refresh: (input) => transport.request((c) => c[STATS_REFRESH_METHOD](input)), +}, +``` +Both are reply RPCs (`transport.request` → `Promise`), mirroring +`mcp.getSnapshot`. `createWsRpcClient` is the only implementer, so no other file changes. + +--- + +## 2. Domain types — `model/types.ts` + +- Re-exports `StatsSession`, `StatsDayBucket`, `StatsCategory` from `@t3tools/contracts` + (single source of truth; also `import`ed locally so the view types can reference it). +- `CATEGORY_LABEL: Record` — Russian labels for the «Тип» column + (Диалог / Заголовок / Ветка / Коммит / PR / Память / Субагент / Сжатие / Служебные). +- `StatsFilters` = `{rangeDays, projectId, model, branch, includeTemp, traffic}`. +- `RangeDays` = `1 | 7 | 14 | 30 | "all"` (calendar-day windows; `"all"` = no cutoff). +- `TrafficFilter` = `"all" | "turns" | "background"`; `Granularity` = `"day" | "week"`. +- View-model types (selector outputs): `KpiSet`, `TimeBucket`, `NamedTokenSlice`, + `ToolStat`, `ToolGroup`, `ErrorStat`, `HeatCell`, `ApprovalSplit`, `LatencyBucket`, + and `StatsView` (the full dashboard payload). +- Local `TokenBreakdown` / `ProjectKind` are structurally equal to the contract's. + +--- + +## 3. Reference data — `model/catalog.ts` + +Trimmed to a presentation-only tool→group reference: `TOOLS: {name, group}[]` and +`TOOL_GROUP_LABEL`. All *data* comes from the server; this only colours/labels tools and +buckets unknowns. (The old fake-data generator + seeded PRNG were deleted — +`generateSessions.ts`, `fakeData.ts`.) + +--- + +## 4. Filter options — `model/filterOptions.ts` (faceted) + +- `RANGE_OPTIONS` = `[{1,Сегодня},{7,7 дней},{14,14 дней},{30,30 дней},{all,Всё время}]` + (string values; `"all"` is the no-cutoff sentinel). +- `projectOptions(scoped, all, selected)` / `modelOptions(scoped, selected)` / + `branchOptions(scoped, selected)` — each derives its options from the **scoped** + session set (sessions that already pass the *other* active filters), prepends the + `"all"` option, and **always keeps the current selection present** even if it has no + data in the window (so the control never blanks). Project labels come from + `projectLabel`; project options sort by label. + +The "scoping" is done by the caller (`StatsFilterBar`) — it passes +`filterSessions(sessions, {...filters, :"all"}, anchorMs)`, i.e. all active filters +with the dropdown's own dimension neutralized. This is true faceted filtering: pick +"Сегодня" and only projects/models/branches with activity today are offered. + +--- + +## 5. Aggregation — `model/selectors.ts` (the heart of the client) + +Pure functions; `buildView(allSessions, filters, granularity, anchorMs): StatsView` is +the entry point. Output `StatsView` shape is consumed unchanged by the widgets. + +**Time windowing (calendar-day, machine-local):** +- `dayKeyFromMs(ms)` = the **browser-local** "YYYY-MM-DD" (`new Date(ms)` local + `getFullYear/getMonth/getDate`). Matches the server's local-zone `tokensByDay` keys + (same machine ⇒ same zone), so "Сегодня" = your real local day. +- `cutoffDayKey(rangeDays, anchorMs)` = earliest in-window day key, or `null` for `"all"`. +- `windowedTokens(session, cutoff)` = sum of the session's `tokensByDay` entries on/after + the cutoff → `{input,output,thinking,cached,apiCalls}`. +- `hasWindowActivity(session, cutoff)` = any day key ≥ cutoff (or any day, when `"all"`). + +**Filtering:** `filterSessions(sessions, filters, anchorMs)` = `matchesDimensions` +(includeTemp / projectId / model / branch) ∧ `matchesTraffic` (turns→!isBackground, +background→isBackground) ∧ `hasWindowActivity`. + +**Selectors (all from the filtered set):** +- `buildKpis` — **windowed** token totals + `apiCalls` (summed from in-window days); + `toolCalls`/`errors` session-grain; `avgLatencyMs` = weighted by whole-session + `apiCalls`; `tokensDeltaPct` vs `previousWindowVisibleTokens` (the equal-length prior + window, from `tokensByDay`); `projects` = distinct `projectId`. +- `buildSeries` — usage-over-time; iterates each session's in-window `tokensByDay`, + bucketed by `day` or `weekStartKey(day)` (Monday-anchored); sorted by key. +- `composition` — `sumWindowedTokens` over the filtered set. +- `groupSlices` — `byModel`/`byProject`/`byBranch`; tokens = **windowed** visible total + per group, with `sessions` count and `sharePct`; sorted desc by tokens. +- `buildTools`/`buildErrors`/`buildApprovals`/`buildLatency` — **session-grain** over the + filtered set (no time axis). `toolGroupForName` falls back to `mcp` for `mcp__*` else + `flow`. +- `buildHeatmap` — from `tokensByWeekdayHour` slots across filtered sessions; parses + `"weekday:hour"` keys, sums tokens, counts sessions per slot. + +**Grain note (intentional):** token/usage metrics are windowed to the in-window days; +tool/error/latency/approval counts are whole-session for any session active in the +window; the heatmap is whole-session (weekday/hour collapses dates). See `audit.md`. + +--- + +## 6. State — `store.ts` (zustand) + +`StatsState`: `filters`, `granularity`, `sessions`, `status` (idle/loading/ready/error), +`errorDetail`, `lastRefreshedAtMs`, `refreshNonce`, `openWidget`, `selectedSessionId`. + +Actions: +- `setSnapshot(sessions, atMs)` → `{sessions, status:"ready", errorDetail:null, + lastRefreshedAtMs:atMs}` (used by both the instant read and a successful refresh). +- `setStatus(status, detail?)` → status-only, **never touches `sessions`** → a failed + refresh keeps the last good data on screen with an error note. +- `requestRefresh()` → bumps `refreshNonce` + sets `loading` (the ⟳ trigger). +- filter/granularity/widget/session setters. + +`DEFAULT_FILTERS` = `{rangeDays:30, projectId:"all", model:"all", branch:"all", +includeTemp:false, traffic:"turns"}` — **default traffic = «Диалог»** (only real +conversations shown until you switch to «Фон»/«Все»). + +`useStatsView()` memoizes `buildView(sessions, filters, granularity, anchorMs)` where +`anchorMs = lastRefreshedAtMs > 0 ? lastRefreshedAtMs : Date.now()`. + +`findSessionById(view, id)` resolves the selected session for the detail sheet. + +> Note: `DEFAULT_FILTERS` is duplicated in `StatsFilterBar.tsx` (used there for the +> "reset" comparison + range parse). Flagged in the audit. + +--- + +## 7. Fetch hook — `useStatsData.ts` + +Mounted once in `StatsDashboard`. Effect deps `[environmentId, refreshNonce, setSnapshot, +setStatus]`. Resolves the primary environment connection; if absent, no-op. + +- `apply(snapshot)` → `setSnapshot(snapshot.sessions, Date.parse(snapshot.generatedAt))`. +- `fail(error)` → `setStatus("error", message)` (keeps data). +- `runRefresh()` → `connection.client.stats.refresh({}).then(apply).catch(fail)`. +- A `refreshNonce` change (vs a `useRef`) means **⟳ was pressed → `refresh` only**. + Otherwise it's an **open** (mount / primary-env change) → `getSnapshot` (instant read, + errors ignored) `.finally` → `runRefresh()`. +- `setStatus("loading")` up front; a `cancelled` flag guards async writes after unmount. + +**No timer, no reconnect trigger** — data loads only on open and on ⟳. + +--- + +## 8. Components + +- **`StatsDashboard.tsx`** — calls `useStatsData()`, reads `useStatsView()` + status; + renders header + `RefreshControl`, `StatsFilterBar`, `KpiStrip`, an error note + (`status==="error"`) and a loading note (`loading` && no sessions), then the widget + grid (`UsageOverTimeCard`, `TokenCompositionCard`, `ModelsCard`, `ProjectsCard`, + `ToolsCard`, `ReliabilityCard`, `ActivityHeatmapCard`, `SessionsTableCard`) and the two + drill-down sheets. Layout `max-w-[1400px]`, responsive `lg:grid-cols-4 xl:grid-cols-6`. +- **`StatsFilterBar.tsx`** — a 2-column grid of controls: period / project / model / + branch `FilterSelect`s (faceted via `useMemo` over `filterSessions` with each + dimension neutralized), the traffic `Segmented` (Все/Диалог/Фон), and a «Песочница» + switch (`includeTemp`); a «Сбросить» button appears when filters differ from default. + `parseRangeDays` maps `"all"`→`"all"` else the numeric range. +- **`RefreshControl.tsx`** — «обновлено N назад» relative-time stamp (`"—"` when never + refreshed) + the ⟳ button (`requestRefresh()`, spinner while `status==="loading"`). + No interval/auto-refresh. +- **`primitives.tsx`** — `FilterSelect` (Base UI Select) now passes + `side="bottom" alignItemWithTrigger={false}` so every dropdown opens **downward** + (matching every other select in the app, instead of the native align-selected-item + default that made the period dropdown open upward). Also `WidgetCard`, `DeltaChip`, + `BarRow`, `Segmented`, chart palette helpers. +- **`widgets/SessionsTableCard.tsx`** — the per-session table; columns Дата · Проект · + **Тип** (`CATEGORY_LABEL[session.category]`, badge: secondary for dialog, outline + otherwise) · Ветка · Токены · Ходы · Инстр. · Длит.; row click → detail sheet; + error dot when `errorTypes` non-empty. +- **`widgets/ModelsCard.tsx`** — tokens-by-model donut with the legend **stacked below** + the chart (`flex flex-col items-center`; legend `w-full`); shows the full model id + (no provider stripping). +- **`components/SessionDetailSheet.tsx`** — single-session drill-down; badges for + branch, model (full id), sandbox, and the category (`CATEGORY_LABEL`). +- Other widgets (`UsageOverTimeCard`, `TokenCompositionCard`, `ProjectsCard`, + `ToolsCard`, `ReliabilityCard`, `ActivityHeatmapCard`, `KpiStrip`, + `WidgetDetailSheet`, `chart.tsx`) are unchanged from the original UI and render the + corresponding `StatsView` slice. + +--- + +## 9. Removed / deleted + +- `model/generateSessions.ts` (seeded PRNG + `DEMO_TODAY`) — deleted. +- `model/fakeData.ts` (fake sessions + static option lists) — deleted. +- All provider-stripping of model ids (`stripModelProvider`, `.replace("qwen/","")`) — + removed; the full model id is shown verbatim. + +--- + +## 10. Verification + +Web has no test target (repo constraint), so the client is validated by +`pnpm typecheck` (14/14) + `pnpm lint` (0/0). The selector logic (the most complex client +code) is therefore **unit-untested** — see `audit.md` for the implication. diff --git a/stats/stats-architecture.md b/stats/stats-architecture.md new file mode 100644 index 00000000000..7761c758100 --- /dev/null +++ b/stats/stats-architecture.md @@ -0,0 +1,227 @@ +# Stats (Analytics) — Architecture + +Generated from the current implementation in worktree `stats-logic` (uncommitted). This +document describes the system as it actually is in the code, not as it was planned. + +--- + +## 1. Purpose + +A read-only **Analytics** panel in Settings («Аналитика») that surfaces CLI usage +analytics — tokens, models, projects, branches, tools, reliability, activity heatmap, +and a per-session table — computed entirely from **qwen's on-disk JSONL chat +transcripts**. Nothing is faked; every number is derived from real telemetry events. + +Scope is a **single user / single machine** personal analytics tool. The design choices +(client-side aggregation, whole-table cache reads, UTC day bucketing) are appropriate +for that scope and are called out where they impose a ceiling. + +--- + +## 2. Data source — qwen transcripts + +qwen stores one JSONL file per chat session at: + +``` +//chats/.jsonl +``` + +`projectsRoot` is resolved by `paths.ts`: +- `QWEN_RUNTIME_DIR` env (tilde/relative-expanded against `$HOME`) if set, else +- `ServerConfig.cliConfigDir` (`{home}/$CLI_DIR`), then `/projects`. + +Each `.jsonl` line is one record. Two things are read: + +**(a) Base record fields** (any record): `cwd`, `gitBranch`, `sessionId`, `type`, +and for `type:"user"` records the `message` text. + +**(b) `ui_telemetry` events** — records with `type:"system"`, `subtype:"ui_telemetry"`, +payload at `systemPayload.uiEvent`, keyed by `event.name`: + +| `event.name` | fields consumed | +|---|---| +| `qwen-code.api_response` | `event.timestamp`, `model`, `input_token_count`, `output_token_count`, `cached_content_token_count`, `thoughts_token_count`, `duration_ms`, `prompt_id` | +| `qwen-code.tool_call` | `event.timestamp`, `function_name`, `success`, `decision` (`auto_accept`/`reject`) | +| `qwen-code.api_error` | `event.timestamp`, `error_type` | + +### prompt_id is the spine of classification + +- `########` — a **real interactive turn** (dialog). +- `side-query:auto-memory-recall` — qwen's background memory recall. +- `compress-` — qwen's context compression. +- `##` — a subagent run (Explore, etc.). +- a **bare hash** (`00d94578d66ad`) — a one-shot call: either **our** server's + text-generation (thread title / branch / commit / PR) **or** a user's `qwen -p`. + These are structurally identical (same id shape, same ~16K input); the only + discriminator is the **content** of the first user message. + +--- + +## 3. The atomic unit: `StatsSession` + +**One chat file = one `StatsSession`.** The server reduces each file's telemetry to a +flat, fully-numeric `StatsSession` (contract: `packages/contracts/src/ru-fork/stats.ts`) +and returns the per-session list; the **web aggregates** that list into every widget, +client-side, so filters re-render instantly with no round-trips. + +A `StatsSession` carries scalar rollups (`tokens`, `apiCalls`, `avgLatencyMs`, +`toolCounts`, `errorTypes`, …) **plus two time-grain rollups** that make time views +correct: +- `tokensByDay`: `Record<"YYYY-MM-DD"(UTC), {input,output,thinking,cached,apiCalls}>` +- `tokensByWeekdayHour`: `Record<"weekday:hour"(Mon=0,UTC), visibleTokens>` + +…and a `category` (see §5), `isBackground`, `present` (retain-after-delete flag), and +`lastSeenAt`. + +--- + +## 4. End-to-end data flow + +``` + qwen JSONL files ──┐ + │ (read-only) + ┌─────────────────▼──────────────────────────── SERVER (apps/server) ──────────┐ + │ StatsScanner.refresh(): │ + │ listDiskFiles → stat each *.jsonl (mtime+size) │ + │ for each CHANGED file: readText → extractFileTelemetry → aggregateSession │ + │ · no api_response → GHOST (skip + purge any stored row) │ + │ · else → upsert StatsSession into stats_file_cache (SQLite) │ + │ vanished files → markAbsent (present=0, retained) │ + │ return all rows as StatsSnapshot │ + │ StatsScanner.getSnapshot(): pure read of stats_file_cache → StatsSnapshot │ + └───────────────▲───────────────────────────────────────────────┬──────────────┘ + │ WS RPC: stats.getSnapshot / stats.refresh │ StatsSnapshot + ┌───────────────┴───────────────────────────────── WEB (apps/web) ─────────────┐ + │ useStatsData(): on open → getSnapshot (instant) then refresh; ⟳ → refresh │ + │ → store.setSnapshot(sessions, generatedAtMs) │ + │ useStatsView(): buildView(sessions, filters, granularity, anchorMs) │ + │ → filterSessions → per-widget selectors (windowed by tokensByDay) │ + │ Dashboard widgets render the StatsView │ + └──────────────────────────────────────────────────────────────────────────────┘ +``` + +### Two server operations (CQRS-style read/refresh split) + +| RPC | Effect | Touches disk? | Use | +|---|---|---|---| +| `stats.getSnapshot` | pure DB read (`listAll`) | **no** | instant safety-net load on panel open | +| `stats.refresh` | scan → re-parse changed → save → return | **yes** | open (after the read) + the ⟳ button | + +**Client triggers (nothing on a timer, nothing in the background):** +- **Open panel** → `getSnapshot` (instant last-good) → then `refresh` (bring current). + A failed refresh keeps the read's data on screen. +- **⟳ button** → `refresh` only (via a store `refreshNonce` bump). + +This satisfies "the server does nothing until you ask" by construction. + +--- + +## 5. Session categories («Тип») + +Roughly **half** of all chat files are not real conversations — they are one-shot / +automatic sessions qwen or our own server spawns per message. The `category` field +(contract `StatsCategory`) makes them visible and filterable. + +Classification (`aggregate.ts::classifyCategory`, first match wins): + +| order | category | signal | bucket | +|---|---|---|---| +| 1 | `dialog` | a `prompt_id` containing `########` | **Диалог** | +| 2 | `memory` | a `prompt_id` starting `side-query:` | Фон | +| 3 | `compress` | a `prompt_id` starting `compress-` | Фон | +| 4 | `subagent` | a `prompt_id` containing `#` | Фон | +| 5 | `title`/`branch`/`commit`/`pr` | first user message contains one of our `SERVICE_SIGNATURES` markers | Фон | +| 6 | `service` | anything else (unknown one-shot, incl. real `qwen -p`) | Фон | + +- `isBackground = category !== "dialog"`. The **«Фон»** traffic filter = everything + that isn't dialog; the default filter is **«Диалог»**, so the panel shows only real + conversations until you switch. +- `turns` counts only `########` ids — automatic sessions report 0 turns. +- The `title/branch/commit/pr` markers match our server's text-generation prompts. + The instruction strings are defined once in + `apps/server/src/textGeneration/instructions.ts`, **used** by `CliTextGeneration.ts` + / `TextGenerationPrompts.ts`, and **imported** by `serviceSignatures.ts` (which keeps + a distinctive substring marker per instruction). A drift-guard test asserts every + marker is a substring of its instruction, so rewording a prompt past its marker fails + a test rather than silently misclassifying. **Honest caveat:** this is still the + *only* available signal (the prompt_id is identical to a user `qwen -p`), so a real + `qwen -p "hi"` is indistinguishable from an unknown service call and lands in + `service`/Фон. + +--- + +## 6. Time model — calendar-day windowing + +Filters are **calendar-day aligned** (`Сегодня · 7 · 14 · 30 · Всё время`). Because +every boundary is a midnight, a per-**day** rollup (`tokensByDay`) is exact: + +- a session is "in window" if it has activity on any in-window day (`hasWindowActivity`); +- token metrics, the usage-over-time chart, composition, and project/model/branch token + totals are **summed from the in-window days** (`windowedTokens`), so a multi-day + session contributes to each day it actually touched — no single-timestamp pinning; +- `Всё время` = `"all"` = no cutoff (truly all history, not "48 days"). + +Day/hour keys are computed in the **machine-local timezone** on both sides: the server +buckets via `Intl` in the local IANA zone (`aggregateSession` takes the zone as a +parameter; the scanner passes `Intl.DateTimeFormat().resolvedOptions().timeZone`), and +the client computes the browser-local day. Same machine ⇒ same zone ⇒ keys line up, so +**"Сегодня" means your real local today.** (Edge case: a *remote* server in a different +timezone would key by the *server's* local day — an accepted trade for the ~1% remote +case. True viewer-local-today with a remote server would require shipping hour-grain +buckets and folding them client-side.) + +`tools / errors / latency / approvals` remain **session-grain** over the filtered set +(they have no time axis). The heatmap uses `tokensByWeekdayHour` (collapses dates, so it +shows the filtered sessions' weekday/hour pattern, not a date-scoped slice). These +grain choices are deliberate and noted in the audit. + +--- + +## 7. Persistence — incremental, durable cache + +`stats_file_cache` (migration `032_Stats.ts`): one row per chat file, keyed by absolute +`file_path`, carrying `(mtime_ms, size_bytes)` for change detection, `present` (0/1), +`last_seen_at`, and `session_json` (the computed `StatsSession`). + +- **Incremental:** `refresh` re-parses only files whose `mtime`/`size` changed. +- **Retain-after-delete:** a vanished file's row is kept with `present=0`; the snapshot + still returns it (reconciled to `present:false`). +- **Ghost purge:** files with no `api_response` are never stored, and any previously + stored ghost row is hard-deleted (`removeByPaths`). +- Unreleased feature → **no migrations/back-compat**; the cache is fully rebuildable. + +--- + +## 8. Module map + +``` +packages/contracts/src/ru-fork/stats.ts contract: StatsSession, StatsSnapshot, + StatsCategory, StatsError, method consts +packages/contracts/src/{index,rpc}.ts re-export + 2 RPC defs + group registration + +apps/server/src/ru-fork/stats/ + paths.ts resolve projectsRoot, chatsDir, temp/label classification (pure) + telemetry.ts JSONL → events + cwd/branch/sessionId + firstUserText (pure) + serviceSignatures.ts category markers, imported from textGeneration/instructions.ts (pure) + aggregate.ts FileTelemetry → StatsSession; classifyCategory; local-zone time buckets (pure) +apps/server/src/textGeneration/instructions.ts single source for the 4 text-gen + instruction strings (used by textGeneration, + imported by serviceSignatures) + StatsScanner.ts the engine: getSnapshot (read) + refresh (scan) — effectful + StatsLayers.ts StatsLive = StatsScannerLive ⊕ repo +apps/server/src/persistence/ + Migrations/032_Stats.ts table DDL + Services/StatsFileCache.ts repo Service + row schema + Layers/ProjectionStatsFileCache.ts repo Live (SqlSchema) +apps/server/src/{server,ws}.ts wiring seams (provideMerge + 2 handlers) + +apps/web/src/rpc/wsRpcClient.ts stats namespace (getSnapshot/refresh) +apps/web/src/ru-fork/stats/ + model/{types,catalog,filterOptions,format,selectors}.ts types + pure aggregation + store.ts zustand state + useStatsView + useStatsData.ts the fetch hook (open→read+refresh, ⟳→refresh) + components/* dashboard, filter bar, refresh control, widgets, sheets +``` + +See `backend.md` and `frontend.md` for full per-file detail, and `audit.md` for the +code-quality assessment.