diff --git a/.env.example b/.env.example index 6d273773d..a8131889f 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,5 @@ BRV_GIT_REMOTE_BASE_URL=http://localhost:8080 BRV_LLM_BASE_URL=http://localhost:3002 BRV_WEB_APP_URL=http://localhost:8080 BRV_BILLING_BASE_URL=http://localhost:3003 +BRV_ANALYTICS_BASE_URL=http://localhost:3004 BRV_UI_SOURCE=lib \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8cdb380e9..66f0f7516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,61 @@ npm run dev:ui:package # Vite dev server resolving shared UI from - **HTTP (nock)**: Must verify `.matchHeader('authorization', ...)` + `.matchHeader('x-byterover-session-id', ...)` - **ES Modules**: Cannot stub ES exports with sinon; test utils with real filesystem (`tmpdir()`) +### M4.7 — analytics e2e against dev-beta (`test/e2e/analytics/dev-beta.e2e.ts`) + +End-to-end smoke for the analytics pipeline (M4.1 -> M4.6). One mocha file that spins up an isolated daemon per scenario under a temp `BRV_DATA_DIR` plus a temp `HOME`, exercises one slice via the real `bin/run.js`, and asserts on JSONL `status` transitions plus scenario-specific runtime state. NOT picked up by `npm test` (glob is `test/**/*.test.ts`; this file is `.e2e.ts`). + +**Run modes**: + +```bash +npm run test:e2e:analytics # all auto scenarios (transition skipped by default) +npm run test:e2e:analytics -- --grep "1 happy" # single scenario +BRV_E2E_TRANSITION=1 npm run test:e2e:analytics -- --grep transition # interactive login scenario +BRV_ANALYTICS_BASE_URL=http://127.0.0.1:3001 npm run test:e2e:analytics # override backend (e.g. local telemetry) +``` + +The npm script chains `npm run build && mocha ...` so `dist/` is always fresh when the test starts. + +Scenarios covered (`describe` names): + +1. `happy` — opt in, emit 1 event, ship within 35s +2. `burst` — 25 events via `analytics:track`, 20-event threshold flush +3. `idle` — 1 event, wait 45s for the interval flush +4. `transition` — anon -> `brv login` -> authed; interactive, gated on `BRV_E2E_TRANSITION=1` +5. `down` — backend down via inline drop-proxy on a random port; failed flush + backoff counters move +6. `disable` — ship 1 baseline, disable, queue more, no further ships + +**Prereqs**: + +- `npm install` (so `bin/run.js` and `node_modules/.bin` are present). `npm link` is NOT required: the test spawns `node /bin/run.js` directly so it always exercises THIS checkout. +- `npm run build` is chained in front of mocha inside the npm script; you only need to run it manually if you invoke `mocha` directly without going through `npm run test:e2e:analytics`. +- Network access to `dev-beta-iam.byterover.dev` and `telemetry-dev.byterover.dev` (the test defaults `BRV_ANALYTICS_BASE_URL` + `BRV_IAM_BASE_URL` to those; override via env to point at a different backend). +- **Backend on the M4.x wire format**: this CLI sends each event's `created_at` as an ISO 8601 string with a timezone designator (e.g. `2026-05-28T21:32:11+07:00` or `...Z`), per the byterover-telemetry backend contract. The top-level `before()` runs one known-good POST and `this.skip()`s the entire suite with a clear reason if the backend rejects it. Last verified green against `https://telemetry-dev.byterover.dev` on 2026-05-29. If a future deployment regresses to the older numeric `timestamp` (epoch milliseconds) schema, every scenario would FAIL with retry-cap exhaustion - coordinate with the telemetry team before running. +- For scenario 4: a browser to complete the OAuth login flow. + +**Test isolation**: each scenario builds a per-scenario `env` object with a temp `BRV_DATA_DIR` and a temp `HOME` and passes it to every `spawnSync(node, [bin/run.js, ...])`. That isolates the analytics JSONL queue, daemon log, auth token store, and the platform-derived global config path (`~/Library/Application Support/brv/config.json` on macOS) away from the developer's real profile. Teardown uses `brv restart` (with the scenario env) instead of `bin/kill-daemon.js` — `restart` properly cleans the SCENARIO's daemon + state files; calling `kill-daemon.js` without scoped env would read the user's real global `daemon.json` and leak the scenario daemon to the process table. The emit helper temporarily mutates `process.env.BRV_DATA_DIR` / `HOME` because `connectToDaemon` reads them for instance discovery, and restores in `finally` - safe because mocha runs scenarios sequentially. Do NOT pass `--parallel`. + +**What "PASS" actually proves**: + +The positive signal is `status: pending -> sent` in the JSONL for explicit post-enable test events. That flip happens only when the M4.2 `HttpAnalyticsSender` sees a 2xx response from the backend. So "PASS" means "the backend accepted the batch with 2xx" - sufficient for the M4.7 "events land within 30s" goal. + +Scenario 5 covers both halves of the M4.7 "backend down -> recovery" test: Phase A boots a TCP drop-proxy on a random localhost port, points the CLI at it, and asserts `backoff.consecutive_failures > 0` after the first flush tick; Phase B closes the drop-proxy and brings up a HTTP accept-proxy on the SAME port, then polls (up to ~90s, since M4.5 exponential backoff delays the next retry) for `consecutive_failures` to drop back to 0 + at least one row to flip to `status=sent`. + +**Postgres-side verification (DoD #7-8) is intentionally NOT in the auto path.** It needs ops-only read credentials that aren't available to the harness. The CLI-side proof (JSONL `status=sent` + backend 2xx) is sufficient for "the pipeline ships and the backend accepts" - asserting the row exists in dev-beta's `raw_events` is a separate ops/manual step. If you have those credentials, the manual SQL is: + +```sql +-- replace device_id with the value from your test's $BRV_DATA_DIR/config.json +SELECT id, event_name, identity_user_id, identity_device_id, received_at +FROM raw_events +WHERE identity_device_id = '' +ORDER BY received_at DESC +LIMIT 25; +``` + +**Scenario 4 operator step**: the test prints the exact `HOME=... BRV_DATA_DIR=... BRV_IAM_BASE_URL=... node /bin/run.js login` command to run in another terminal. Use that exact command so the login writes into the scenario's isolated token/config paths instead of your normal profile. + +**Estimated runtime**: ~3 minutes for the auto suite (driven by the 30-45s interval windows in scenarios 3 + 5 + 6). + ## Conventions - ES modules with `.js` import extensions required diff --git a/eslint.config.mjs b/eslint.config.mjs index dd28e7577..93f2d45ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,24 @@ export default [ }, }, }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + ignores: ['src/server/infra/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/analytics/i-analytics-client', '**/analytics/i-analytics-client.js'], + message: + 'IAnalyticsClient is daemon-internal. Only code under src/server/infra/ may import it; other consumers should use the transport event analytics:track (M2.6).', + }, + ], + }, + ], + }, + }, // Web UI (browser environment) — allow browser globals and React naming conventions { files: ['src/webui/**/*.ts', 'src/webui/**/*.tsx'], diff --git a/package-lock.json b/package-lock.json index 75af0728d..6e9eb1ca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "byterover-cli", - "version": "3.16.0", + "version": "3.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "byterover-cli", - "version": "3.16.0", + "version": "3.16.1", "bundleDependencies": [ "@campfirein/brv-transport-client", "@campfirein/byterover-packages", @@ -41,7 +41,7 @@ "@ai-sdk/xai": "^2.0.57", "@anthropic-ai/sdk": "^0.70.1", "@campfirein/brv-transport-client": "github:campfirein/brv-transport-client#1.1.0", - "@campfirein/byterover-packages": "github:campfirein/byterover-packages#main", + "@campfirein/byterover-packages": "github:campfirein/byterover-packages#1.0.5", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.3", @@ -3301,7 +3301,7 @@ }, "node_modules/@campfirein/byterover-packages": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/campfirein/byterover-packages.git#2dec813d8dcb473d46ff575d043391ddb423efd2", + "resolved": "git+ssh://git@github.com/campfirein/byterover-packages.git#72327af1e8b9506d65cd989fb6879e28cadee663", "inBundle": true, "dependencies": { "@base-ui/react": "^1.3.0", @@ -6251,7 +6251,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6272,7 +6271,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6293,7 +6291,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6314,7 +6311,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6335,7 +6331,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6356,7 +6351,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6377,7 +6371,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6398,7 +6391,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6419,7 +6411,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6440,7 +6431,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" }, @@ -6461,7 +6451,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" }, diff --git a/package.json b/package.json index cec7fe23a..c4e96f5bd 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@ai-sdk/xai": "^2.0.57", "@anthropic-ai/sdk": "^0.70.1", "@campfirein/brv-transport-client": "github:campfirein/brv-transport-client#1.1.0", - "@campfirein/byterover-packages": "github:campfirein/byterover-packages#main", + "@campfirein/byterover-packages": "github:campfirein/byterover-packages#1.0.5", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.3", @@ -206,7 +206,7 @@ }, "repository": "campfirein/byterover-cli", "scripts": { - "build": "shx rm -rf dist && tsc -b && shx cp -r src/server/templates dist/server/templates && shx cp -r src/agent/resources dist/agent/resources && npm run build:ui", + "build": "shx rm -rf dist && tsc -b && shx cp -r src/server/templates dist/server/templates && shx cp -r src/agent/resources dist/agent/resources && shx cp -r src/shared/assets dist/shared/assets && npm run build:ui", "build:ui": "vite build src/webui --mode package", "build:ui:submodule": "node scripts/prepare-ui-submodule-links.mjs && vite build src/webui --mode submodule", "dev": "node bin/kill-daemon.js && npm run build && ./bin/dev.js", @@ -219,6 +219,9 @@ "prepack": "npm run build && BRV_ENV=production oclif manifest", "prepare": "husky", "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "test:e2e:analytics": "npm run build && mocha --forbid-only --timeout 600000 \"test/e2e/analytics/dev-beta.e2e.ts\"", + "test:e2e:lifecycle": "npm run build && mocha --forbid-only --timeout 180000 \"test/e2e/analytics/lifecycle-wire.e2e.ts\"", + "test:e2e:db": "npm run build && mocha --forbid-only --timeout 300000 \"test/e2e/analytics/lifecycle-db.e2e.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p src/webui/tsconfig.json", "version": "git add README.md" }, diff --git a/src/oclif/commands/locations.ts b/src/oclif/commands/locations.ts index 954eef96b..26b5f92d4 100644 --- a/src/oclif/commands/locations.ts +++ b/src/oclif/commands/locations.ts @@ -1,9 +1,15 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' import chalk from 'chalk' import type {ProjectLocationDTO} from '../../shared/transport/types/dto.js' -import {LocationsEvents, type LocationsGetResponse} from '../../shared/transport/events/locations-events.js' +import { + LocationsEvents, + type LocationsGetRequest, + type LocationsGetResponse, +} from '../../shared/transport/events/locations-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -19,19 +25,27 @@ export default class Locations extends Command { }), } - protected async fetchLocations(options?: DaemonClientOptions): Promise { + protected async fetchLocations( + cliMetadata: ReturnType, + options?: DaemonClientOptions, + ): Promise { return withDaemonRetry(async (client) => { - const response = await client.requestWithAck(LocationsEvents.GET) + const request: LocationsGetRequest = {cli_metadata: cliMetadata} + const response = await client.requestWithAck( + LocationsEvents.GET, + request, + ) return response.locations }, options) } public async run(): Promise { - const {flags} = await this.parse(Locations) + const {flags, metadata} = await this.parse(Locations) const isJson = flags.format === 'json' + const cliMetadata = buildCliMetadata(this.id ?? 'locations', {flags, metadata}) try { - const locations = await this.fetchLocations({projectPath: process.cwd()}) + const locations = await this.fetchLocations(cliMetadata, {projectPath: process.cwd()}) if (isJson) { writeJsonResponse({command: 'locations', data: {locations}, success: true}) diff --git a/src/oclif/commands/login.ts b/src/oclif/commands/login.ts index c50a42b3c..a4bec603c 100644 --- a/src/oclif/commands/login.ts +++ b/src/oclif/commands/login.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' import { @@ -6,6 +7,7 @@ import { type AuthLoginWithApiKeyResponse, type AuthStartLoginResponse, } from '../../shared/transport/events/auth-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -54,14 +56,25 @@ export default class Login extends Command { return true } - protected async loginWithApiKey(apiKey: string, options?: DaemonClientOptions): Promise { + protected async loginWithApiKey( + apiKey: string, + cliMetadata: ReturnType, + options?: DaemonClientOptions, + ): Promise { return withDaemonRetry( - async (client) => client.requestWithAck(AuthEvents.LOGIN_WITH_API_KEY, {apiKey}), + async (client) => + client.requestWithAck(AuthEvents.LOGIN_WITH_API_KEY, { + apiKey, + cli_metadata: cliMetadata, + }), options, ) } - protected async loginWithOAuth(options?: LoginOAuthOptions): Promise { + protected async loginWithOAuth( + cliMetadata: ReturnType, + options?: LoginOAuthOptions, + ): Promise { const timeoutMs = options?.oauthTimeoutMs ?? DEFAULT_OAUTH_TIMEOUT_MS return withDaemonRetry(async (client) => { @@ -87,7 +100,9 @@ export default class Login extends Command { }) try { - const startResponse = await client.requestWithAck(AuthEvents.START_LOGIN) + const startResponse = await client.requestWithAck(AuthEvents.START_LOGIN, { + cli_metadata: cliMetadata, + }) options?.onAuthUrl?.(startResponse.authUrl) return await completion @@ -104,9 +119,10 @@ export default class Login extends Command { } public async run(): Promise { - const {flags} = await this.parse(Login) + const {flags, metadata} = await this.parse(Login) const apiKey = flags['api-key'] const format: OutputFormat = flags.format === 'json' ? 'json' : 'text' + const cliMetadata = buildCliMetadata(this.id ?? 'login', {flags, metadata}) if (!apiKey && !this.canOpenBrowser()) { this.emitError( @@ -117,7 +133,7 @@ export default class Login extends Command { } try { - await (apiKey ? this.runApiKey(apiKey, format) : this.runOAuth(format)) + await (apiKey ? this.runApiKey(apiKey, format, cliMetadata) : this.runOAuth(format, cliMetadata)) } catch (error) { const message = formatConnectionError(error) if (format === 'json') { @@ -144,12 +160,16 @@ export default class Login extends Command { } } - private async runApiKey(apiKey: string, format: OutputFormat): Promise { + private async runApiKey( + apiKey: string, + format: OutputFormat, + cliMetadata: ReturnType, + ): Promise { if (format === 'text') { this.log('Logging in...') } - const response = await this.loginWithApiKey(apiKey) + const response = await this.loginWithApiKey(apiKey, cliMetadata) if (response.success) { this.emitSuccess(format, response.userEmail) @@ -158,7 +178,7 @@ export default class Login extends Command { } } - private async runOAuth(format: OutputFormat): Promise { + private async runOAuth(format: OutputFormat, cliMetadata: ReturnType): Promise { const onAuthUrl = (authUrl: string): void => { if (format === 'text') { this.log('Opening browser for authentication...') @@ -166,7 +186,7 @@ export default class Login extends Command { } } - const result = await this.loginWithOAuth({onAuthUrl}) + const result = await this.loginWithOAuth(cliMetadata, {onAuthUrl}) if (result.success) { this.emitSuccess(format, result.user?.email) diff --git a/src/oclif/commands/logout.ts b/src/oclif/commands/logout.ts index ba94b49d8..1a3c7f46c 100644 --- a/src/oclif/commands/logout.ts +++ b/src/oclif/commands/logout.ts @@ -1,6 +1,12 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' -import {AuthEvents, type AuthLogoutResponse} from '../../shared/transport/events/auth-events.js' +import { + AuthEvents, + type AuthLogoutRequest, + type AuthLogoutResponse, +} from '../../shared/transport/events/auth-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -20,23 +26,30 @@ export default class Logout extends Command { }), } - protected async performLogout(options?: DaemonClientOptions): Promise { + protected async performLogout( + cliMetadata: ReturnType, + options?: DaemonClientOptions, + ): Promise { return withDaemonRetry( - async (client) => client.requestWithAck(AuthEvents.LOGOUT), + async (client) => { + const request: AuthLogoutRequest = {cli_metadata: cliMetadata} + return client.requestWithAck(AuthEvents.LOGOUT, request) + }, options, ) } public async run(): Promise { - const {flags} = await this.parse(Logout) + const {flags, metadata} = await this.parse(Logout) const format = flags.format ?? 'text' + const cliMetadata = buildCliMetadata(this.id ?? 'logout', {flags, metadata}) try { if (format === 'text') { this.log('Logging out...') } - const response = await this.performLogout() + const response = await this.performLogout(cliMetadata) if (response.success) { if (format === 'json') { diff --git a/src/oclif/commands/search.ts b/src/oclif/commands/search.ts index 389fb1938..e630e6304 100644 --- a/src/oclif/commands/search.ts +++ b/src/oclif/commands/search.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' import {Args, Command, Flags} from '@oclif/core' @@ -7,6 +8,7 @@ import type {SearchKnowledgeResult} from '../../agent/infra/sandbox/tools-sdk.js import {TaskEvents} from '../../shared/transport/events/index.js' import {encodeSearchContent} from '../../shared/transport/search-content.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import { type DaemonClientOptions, formatConnectionError, @@ -64,17 +66,20 @@ Use "brv query" when you need a synthesized answer.` } public async run(): Promise { - const {args, flags} = await this.parse(Search) + const {args, flags, metadata} = await this.parse(Search) const format: 'json' | 'text' = flags.format === 'json' ? 'json' : 'text' if (!this.validateInput(args.query, format)) return + const cliMetadata = buildCliMetadata(this.id ?? 'search', {flags, metadata}) + try { await withDaemonRetry( async (client, projectRoot, worktreeRoot) => { // No provider validation — search is pure BM25, no LLM needed. await this.submitTask({ client, + cliMetadata, format, limit: flags.limit, projectRoot, @@ -114,6 +119,7 @@ Use "brv query" when you need a synthesized answer.` private async submitTask(props: { client: ITransportClient + cliMetadata: ReturnType format: 'json' | 'text' limit?: number projectRoot?: string @@ -121,12 +127,13 @@ Use "brv query" when you need a synthesized answer.` scope?: string worktreeRoot?: string }): Promise { - const {client, format, projectRoot, query, worktreeRoot} = props + const {client, cliMetadata, format, projectRoot, query, worktreeRoot} = props const taskId = randomUUID() const contentPayload = encodeSearchContent({limit: props.limit, query, scope: props.scope}) const taskPayload = { + cli_metadata: cliMetadata, clientCwd: process.cwd(), content: contentPayload, ...(projectRoot ? {projectPath: projectRoot} : {}), diff --git a/src/oclif/commands/settings/get.ts b/src/oclif/commands/settings/get.ts index 34ad47a6f..dcdf41368 100644 --- a/src/oclif/commands/settings/get.ts +++ b/src/oclif/commands/settings/get.ts @@ -1,12 +1,18 @@ import {Args, Command, Flags} from '@oclif/core' +import {SETTINGS_KEYS} from '../../../server/core/domain/entities/settings.js' import { SettingsEvents, type SettingsGetRequest, type SettingsGetResponse, type SettingsItemDTO, } from '../../../shared/transport/events/settings-events.js' +// Side-effect import: registers the analytics.status formatter so +// `formatReadonlyInfoValue('analytics.status', ...)` in `printTextBlock` +// returns the legacy text shape for the direct `brv settings get` path. +import {formatAnalyticsStatusJson} from '../../../shared/utils/format-analytics-status.js' import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js' +import {formatReadonlyInfoValue} from '../../../shared/utils/format-readonly-info.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' @@ -74,9 +80,22 @@ export default class SettingsGet extends Command { } private printTextBlock(item: SettingsItemDTO): void { + if (item.type === 'readonly-info') { + // Print the snapshot text verbatim so `brv settings get analytics.status` + // matches the deleted `brv analytics status` (its predecessor) + // output character-for-character. + // No `` header / `current:` prefix / `scope:` footer — the chrome + // is reserved for writable variants where it carries meaningful labels. + this.log(formatReadonlyInfoValue(item.key, item.current)) + return + } + this.log(item.key) - this.log(` current: ${renderValue(item, item.current)}`) - this.log(` default: ${renderValue(item, item.default)}`) + this.log(` current: ${renderWritableValue(item, item.current)}`) + if (item.default !== undefined) { + this.log(` default: ${renderWritableValue(item, item.default)}`) + } + if (item.type === 'integer' && item.min !== undefined && item.max !== undefined) { const range = `${renderInteger(item, item.min)}-${renderInteger(item, item.max)}` this.log(` range: ${range}`) @@ -86,16 +105,24 @@ export default class SettingsGet extends Command { } private toJsonPayload(item: SettingsItemDTO): Record { + // M16.3: `analytics.status` keeps the legacy snake_case envelope of + // the deleted `brv analytics status --format json` (now + // `brv settings get analytics.status --format json`) so callers that + // already script against that wire shape are not broken. + if (item.key === SETTINGS_KEYS.ANALYTICS_STATUS) { + return {...formatAnalyticsStatusJson(item.current)} + } + const payload: Record = { current: item.current, - default: item.default, description: item.description, key: item.key, - max: item.max, - min: item.min, restartRequired: item.restartRequired, type: item.type, } + if (item.default !== undefined) payload.default = item.default + if (item.min !== undefined) payload.min = item.min + if (item.max !== undefined) payload.max = item.max if (item.category !== undefined) payload.category = item.category if (item.unit !== undefined) payload.unit = item.unit if (item.scope !== undefined) payload.scope = item.scope @@ -103,9 +130,14 @@ export default class SettingsGet extends Command { } } -function renderValue(item: SettingsItemDTO, value: boolean | number): string { +function renderWritableValue( + item: SettingsItemDTO, + value: boolean | number | Readonly> | undefined, +): string { + if (value === undefined) return '' if (typeof value === 'boolean') return value ? 'true' : 'false' - return renderInteger(item, value) + if (typeof value === 'number') return renderInteger(item, value) + return JSON.stringify(value) } function renderInteger(item: SettingsItemDTO, value: number): string { diff --git a/src/oclif/commands/settings/index.ts b/src/oclif/commands/settings/index.ts index 9375c30e6..9a87311a5 100644 --- a/src/oclif/commands/settings/index.ts +++ b/src/oclif/commands/settings/index.ts @@ -5,15 +5,22 @@ import { type SettingsItemDTO, type SettingsListResponse, } from '../../../shared/transport/events/settings-events.js' +// Side-effect import: registers the `analytics.status` readonly-info text +// formatter into the shared registry. Without this, a cold-start `brv settings` +// invocation that does not transitively load any other module containing the +// side-effect would render `analytics.status` rows as a raw JSON dump. +import '../../../shared/utils/format-analytics-status.js' import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js' +import {formatReadonlyInfoValue} from '../../../shared/utils/format-readonly-info.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' -type CategoryName = 'concurrency' | 'llm' | 'task-history' | 'updates' +type CategoryName = 'analytics' | 'concurrency' | 'llm' | 'task-history' | 'updates' -const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history', 'updates'] +const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history', 'updates', 'analytics'] const CATEGORY_HEADERS: Readonly> = { + analytics: 'ANALYTICS', concurrency: 'CONCURRENCY', llm: 'LLM', 'task-history': 'TASK HISTORY', @@ -108,15 +115,29 @@ function groupByCategory(items: readonly SettingsItemDTO[]): Map`. + const fullText = formatReadonlyInfoValue(item.key, item.current) + const headline = fullText.split('\n')[0] + return ` ${pad(item.key, 40)} ${headline}` + } + + const current = renderWritableValue(item, item.current) + const defaultStr = item.default === undefined ? '' : renderWritableValue(item, item.default) const range = renderRange(item) return ` ${pad(item.key, 40)} ${pad(current, 7)} (default ${defaultStr})${''.padEnd(Math.max(0, 8 - defaultStr.length))} ${range}` } -function renderValue(item: SettingsItemDTO, value: boolean | number): string { +function renderWritableValue(item: SettingsItemDTO, value: boolean | number | Readonly> | undefined): string { + if (value === undefined) return '' if (typeof value === 'boolean') return value ? 'true' : 'false' - return renderInteger(item, value) + if (typeof value === 'number') return renderInteger(item, value) + // Defensive — writable descriptors never carry object payloads. If a + // future regression smuggles one in, format-via-JSON instead of NaN. + return JSON.stringify(value) } function renderInteger(item: SettingsItemDTO, value: number): string { diff --git a/src/oclif/commands/settings/reset.ts b/src/oclif/commands/settings/reset.ts index 57d00803f..f0bf68664 100644 --- a/src/oclif/commands/settings/reset.ts +++ b/src/oclif/commands/settings/reset.ts @@ -63,6 +63,22 @@ export default class SettingsReset extends Command { return } + if (descriptor.type === 'readonly-info') { + process.exitCode = 1 + const message = `Setting '${args.key}' is read-only and cannot be reset.` + if (format === 'json') { + writeJsonResponse({ + command: 'settings reset', + data: {error: {code: 'read_only', key: args.key, message}}, + success: false, + }) + } else { + this.log(message) + } + + return + } + const response = await this.resetSetting(args.key) if (response.ok) { @@ -73,7 +89,8 @@ export default class SettingsReset extends Command { success: true, }) } else { - const base = `Setting reset: ${args.key} back to default (${renderValue(descriptor, descriptor.default)}).` + const defaultDisplay = descriptor.default === undefined ? '(none)' : renderValue(descriptor, descriptor.default) + const base = `Setting reset: ${args.key} back to default (${defaultDisplay}).` this.log(descriptor.restartRequired ? `${base} Run \`brv restart\` to apply.` : base) } diff --git a/src/oclif/commands/settings/set.ts b/src/oclif/commands/settings/set.ts index bdc33eb8a..cd11d6aba 100644 --- a/src/oclif/commands/settings/set.ts +++ b/src/oclif/commands/settings/set.ts @@ -1,5 +1,6 @@ -import {Args, Command, Flags} from '@oclif/core' +import {Args, Command, Errors, Flags} from '@oclif/core' +import {SETTINGS_KEYS} from '../../../server/core/domain/entities/settings.js' import { SettingsEvents, type SettingsGetRequest, @@ -14,6 +15,7 @@ import { formatDuration, parseDuration, } from '../../../shared/utils/format-duration.js' +import {collectConsent} from '../../lib/analytics-disclosure.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' @@ -41,6 +43,15 @@ export default class SettingsSet extends Command { description: 'Output format (text or json)', options: ['text', 'json'], }), + // Accepts the analytics disclosure non-interactively. Only meaningful when + // setting `analytics.share true` (the one consent-gated key). Passing it + // for any other key emits `this.warn(...)` so the user does not silently + // rely on a flag that has no behavioural effect for their command. + yes: Flags.boolean({ + char: 'y', + default: false, + description: 'Accept the analytics disclosure non-interactively (only meaningful for analytics.share)', + }), } protected async fetchDescriptor(key: string, options?: DaemonClientOptions): Promise { @@ -55,6 +66,15 @@ export default class SettingsSet extends Command { const {args, flags} = await this.parse(SettingsSet) const format = flags.format as 'json' | 'text' + // `--yes` is only meaningful for the one consent-gated key. Warn (don't + // refuse) for any other key so automation scripts don't silently rely on + // a flag that has no behavioural effect. + if (flags.yes && args.key !== SETTINGS_KEYS.ANALYTICS_ENABLED) { + this.warn( + `--yes is only meaningful for ${SETTINGS_KEYS.ANALYTICS_ENABLED}; ignored for '${args.key}'.`, + ) + } + try { const descriptor = await this.fetchDescriptor(args.key) if (!descriptor.ok) { @@ -68,6 +88,22 @@ export default class SettingsSet extends Command { return } + if (descriptor.type === 'readonly-info') { + process.exitCode = 1 + const message = `Setting '${args.key}' is read-only and cannot be written.` + if (format === 'json') { + writeJsonResponse({ + command: 'settings set', + data: {error: {code: 'read_only', key: args.key, message}}, + success: false, + }) + } else { + this.log(message) + } + + return + } + const parsed = parseValue(descriptor, args.value) if (parsed.kind === 'error') { process.exitCode = 1 @@ -84,6 +120,67 @@ export default class SettingsSet extends Command { return } + // Enable-to-true on `analytics.share` triggers the disclosure + // prompt. Idempotent (no prompt if already enabled), false-unchanged, + // and other keys unaffected. `collectConsent`'s `onError` calls + // `this.error()` which throws CLIError; we let it propagate to + // oclif's exit handler (clean message, non-zero exit). + if ( + args.key === SETTINGS_KEYS.ANALYTICS_ENABLED && + parsed.value === true && + descriptor.current !== true + ) { + // JSON output mode cannot host an interactive consent prompt: the + // disclosure markdown (and inquirer's own prompt frame) would land + // on stdout BEFORE the final JSON envelope, breaking parseability + // for any caller piping the output. Refuse with a structured error + // envelope instructing the caller to pass `--yes` (which both + // confirms consent and skips the markdown print). + if (format === 'json' && !flags.yes) { + process.exitCode = 1 + writeJsonResponse({ + command: 'settings set', + data: { + error: { + code: 'requires_consent', + key: args.key, + message: + `Enabling ${SETTINGS_KEYS.ANALYTICS_ENABLED} requires accepting the disclosure. ` + + 'Re-run with --yes to accept non-interactively, or omit --format json to see the disclosure.', + }, + }, + success: false, + }) + return + } + + // In JSON+--yes mode the consent gate is already satisfied. Skip + // `collectConsent` entirely so its `onLog(disclosureMarkdown)` does + // not pollute the JSON envelope on stdout. + const accepted = + format === 'json' && flags.yes + ? true + : await collectConsent({ + onError: (msg) => this.error(msg), + onLog: (msg) => this.log(msg), + yesFlag: flags.yes, + }) + + if (!accepted) { + if (format === 'json') { + writeJsonResponse({ + command: 'settings set', + data: {accepted: false, key: args.key}, + success: true, + }) + } else { + this.log('Analytics not enabled') + } + + return + } + } + const response = await this.writeSetting(args.key, parsed.value) if (response.ok) { @@ -108,6 +205,14 @@ export default class SettingsSet extends Command { this.log(response.error.message) } } catch (error) { + // CLIError thrown from `this.error()` (e.g. the non-TTY disclosure + // guard) carries its own clean message + exit code via oclif's exit + // handler — let it propagate untouched. Everything else gets the + // daemon-connection-friendly formatter. + if (error instanceof Errors.CLIError) { + throw error + } + process.exitCode = 1 if (format === 'json') { writeJsonResponse({command: 'settings set', data: {error: formatConnectionError(error)}, success: false}) diff --git a/src/oclif/commands/status.ts b/src/oclif/commands/status.ts index 6f98433df..5f1428cd0 100644 --- a/src/oclif/commands/status.ts +++ b/src/oclif/commands/status.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' import chalk from 'chalk' @@ -8,6 +9,7 @@ import { type StatusGetRequest, type StatusGetResponse, } from '../../shared/transport/events/status-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -33,8 +35,15 @@ export default class Status extends Command { }), } - protected async fetchStatus(options?: DaemonClientOptions & {projectRootFlag?: string}): Promise { - const request: StatusGetRequest = {cwd: process.cwd(), projectRootFlag: options?.projectRootFlag} + protected async fetchStatus( + cliMetadata: ReturnType, + options?: DaemonClientOptions & {projectRootFlag?: string}, + ): Promise { + const request: StatusGetRequest = { + cli_metadata: cliMetadata, + cwd: process.cwd(), + projectRootFlag: options?.projectRootFlag, + } return withDaemonRetry(async (client) => { const response = await client.requestWithAck(StatusEvents.GET, request) return response.status @@ -42,12 +51,13 @@ export default class Status extends Command { } public async run(): Promise { - const {flags} = await this.parse(Status) + const {flags, metadata} = await this.parse(Status) const projectRootFlag = flags['project-root'] const isJson = flags.format === 'json' + const cliMetadata = buildCliMetadata(this.id ?? 'status', {flags, metadata}) try { - const status = await this.fetchStatus({projectPath: process.cwd(), projectRootFlag}) + const status = await this.fetchStatus(cliMetadata, {projectPath: process.cwd(), projectRootFlag}) if (isJson) { writeJsonResponse({ diff --git a/src/oclif/lib/analytics-disclosure.ts b/src/oclif/lib/analytics-disclosure.ts new file mode 100644 index 000000000..9fd6e278c --- /dev/null +++ b/src/oclif/lib/analytics-disclosure.ts @@ -0,0 +1,99 @@ +import {confirm} from '@inquirer/prompts' + +import {loadAnalyticsDisclosureText} from '../../shared/utils/load-analytics-disclosure.js' + +/** + * Disclosure markdown lives in `src/shared/assets/analytics-disclosure.md` + * so the same canonical text is consumed by oclif (CLI consent prompt), + * TUI (settings-page inline confirm), and any future WebUI render. + */ +export async function loadDisclosure(): Promise { + return loadAnalyticsDisclosureText() +} + +export async function confirmDisclosure(): Promise { + return confirm({default: false, message: 'Enable analytics with the terms above?'}) +} + +export function isInteractive(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true +} + +export interface CollectConsentDeps { + /** + * Optional override for `loadDisclosure`. Tests inject a fixture string + * to avoid touching disk. Defaults to the lib's `loadDisclosure` + * (reads `analytics-disclosure.md` from disk). + */ + readonly loadFn?: () => Promise + /** + * Called when consent cannot be collected (non-TTY without `--yes`). + * Implementations are expected to throw — oclif's `this.error()` + * surfaces a non-zero exit code via `CLIError`. Typed `never` so + * callers cannot forget to terminate. + */ + readonly onError: (message: string) => never + /** Receives the disclosure markdown text. Typically `this.log` from a Command. */ + readonly onLog: (message: string) => void + /** + * The prompt function. Defaults to `confirmDisclosure` (inquirer). + * Tests inject a stub to avoid mounting an interactive TTY. + */ + readonly promptFn?: () => Promise + /** + * TTY check. Defaults to `isInteractive`. Tests inject a stub to + * exercise both the TTY and non-TTY branches. + */ + readonly ttyCheck?: () => boolean + /** When true, skip the prompt and accept silently. CI / non-interactive use. */ + readonly yesFlag: boolean +} + +/** + * M1.4 disclosure consent flow. + * + * 1. Load and print the disclosure markdown (via `loadFn` if provided, + * else the default `loadDisclosure`). + * 2. If `--yes` is set, accept silently. + * 3. If the session is non-interactive (no TTY), call `onError` (which + * throws — non-zero exit). Re-running in a terminal or passing `--yes` + * is the documented escape. + * 4. Otherwise, prompt and return the user's choice. + * + * Extracted from the legacy `brv analytics enable` command in M16.2 so + * `brv settings set analytics.share true` can reuse the exact same + * consent gate. M16.4 then deleted the legacy command; this lib is now + * the sole consent surface. + */ +export async function collectConsent(deps: CollectConsentDeps): Promise { + const load = deps.loadFn ?? loadDisclosure + const disclosure = await load() + deps.onLog(disclosure) + + if (deps.yesFlag) return true + + const tty = deps.ttyCheck ?? isInteractive + if (!tty()) { + deps.onError( + 'Cannot enable analytics in non-interactive mode without confirmation.\n' + + 'Re-run in a terminal, or pass --yes to accept the disclosure non-interactively.', + ) + } + + const prompt = deps.promptFn ?? confirmDisclosure + try { + return await prompt() + } catch (error) { + // Ctrl-C inside `@inquirer/prompts` rejects with `ExitPromptError`. Without + // this guard, the rejection surfaces as a raw stack trace from the + // caller's `formatConnectionError` fallback. Treat Ctrl-C as a declined + // consent so the caller logs "Analytics not enabled" and exits cleanly. + // Match by `name` (not `instanceof`) because the running `@inquirer/core` + // copy depends on which nested node_modules path resolved at runtime. + if (error instanceof Error && error.name === 'ExitPromptError') { + return false + } + + throw error + } +} diff --git a/src/oclif/lib/analytics-status-formatter.ts b/src/oclif/lib/analytics-status-formatter.ts new file mode 100644 index 000000000..cb7c3526d --- /dev/null +++ b/src/oclif/lib/analytics-status-formatter.ts @@ -0,0 +1,13 @@ +/** + * Stable named re-exports of the analytics-status text and JSON + * formatters, satisfying the ticket AC that they be importable from + * `src/oclif/lib/`. The canonical home is `src/shared/utils/` because + * the TUI also consumes the same renderer and cannot import from + * `src/oclif/` per the architecture import boundary. + */ +export { + formatAnalyticsStatusJson, + formatAnalyticsStatusText, + formatDelayMs, + formatRelativeAgo, +} from '../../shared/utils/format-analytics-status.js' diff --git a/src/oclif/lib/build-cli-metadata.ts b/src/oclif/lib/build-cli-metadata.ts new file mode 100644 index 000000000..70d8a45dd --- /dev/null +++ b/src/oclif/lib/build-cli-metadata.ts @@ -0,0 +1,92 @@ +/* eslint-disable camelcase */ +import type {CliMetadata} from '../../shared/analytics/cli-metadata-schema.js' + +type PackageManager = 'bun' | 'npm' | 'pnpm' | 'unknown' | 'yarn' + +/** + * Shape of the oclif `Command.parse()` result fields this helper consumes. + * Both top-level fields are accepted defensively (test fixtures may pass + * a `flags`-only shape without metadata). + */ +export type OclifParseResultLike = { + flags: Record + metadata?: { + flags?: Record + } +} + +/** + * Detect the package manager that launched this `brv` process. + * + * `npm`, `yarn`, `pnpm`, and `bun` all set `npm_config_user_agent` when + * they spawn a child script (e.g. `npm install` → `npm/X.Y.Z node/Z os`). + * Direct `node bin/run.js` invocations or unknown package managers fall + * through to `'unknown'`. + */ +function detectPackageManager(): PackageManager { + const userAgent = process.env.npm_config_user_agent ?? '' + if (userAgent.startsWith('npm/')) return 'npm' + if (userAgent.startsWith('yarn/')) return 'yarn' + if (userAgent.startsWith('pnpm/')) return 'pnpm' + if (userAgent.startsWith('bun/')) return 'bun' + return 'unknown' +} + +/** + * `process.versions.bun` is `string` under Bun, absent under Node. The + * `in` operator narrows without needing an explicit cast on `process.versions`. + */ +function detectRuntime(): 'bun' | 'node' { + return 'bun' in process.versions ? 'bun' : 'node' +} + +/** + * Strict CI detection: only treat the standard `CI=true` / `CI=1` as CI. + * Many tools set `CI=false` to opt out — we honour that by returning false. + */ +function detectIsCi(): boolean { + const ci = process.env.CI + return ci === '1' || ci === 'true' +} + +function detectIsTty(): boolean { + return Boolean(process.stdout.isTTY) +} + +function detectTerminalProgram(): string | undefined { + const term = process.env.TERM_PROGRAM + return typeof term === 'string' && term.length > 0 ? term : undefined +} + +/** + * Compose the `cli_metadata` block from CLI-process detections. Pure + * function: no transport calls, no async work, no side effects beyond + * reading `process.env` / `process.stdout`. Returns a fresh object per call. + * + * The helper is called ONCE per `run()` so a single `client_sent_at` value + * identifies one CLI invocation across multi-request commands (per M13.3). + * + * `flag_names` captures ONLY the flag KEY names the user actually typed + * on the command line. Flags whose value came from oclif's static-default + * machinery (i.e. `metadata.flags[name].setFromDefault === true`) are + * excluded — otherwise every invocation would look like the user passed + * every flag, since oclif fills the entire flag object regardless of argv. + * Flag VALUES are NEVER captured — they may carry paths, query text, or + * secrets. + */ +export function buildCliMetadata(commandId: string, parsed: OclifParseResultLike): CliMetadata { + const flagMeta = parsed.metadata?.flags ?? {} + const flagNames = Object.keys(parsed.flags).filter((name) => flagMeta[name]?.setFromDefault !== true) + const terminalProgram = detectTerminalProgram() + const metadata: CliMetadata = { + client_sent_at: Date.now(), + command_id: commandId, + flag_names: flagNames, + is_ci: detectIsCi(), + is_tty: detectIsTty(), + package_manager: detectPackageManager(), + runtime: detectRuntime(), + ...(terminalProgram === undefined ? {} : {terminal_program: terminalProgram}), + } + return metadata +} diff --git a/src/server/config/environment.ts b/src/server/config/environment.ts index 896bb7b9a..e3c950e8d 100644 --- a/src/server/config/environment.ts +++ b/src/server/config/environment.ts @@ -1,4 +1,5 @@ import {API_V1_PATH} from '../constants.js' +import {processLog} from '../utils/process-logger.js' /** * Environment types supported by the CLI. @@ -26,6 +27,16 @@ export const ENVIRONMENT: Environment = isEnvironment(envValue) ? envValue : 'de * that does not follow the general "API version at point of use" pattern. */ type EnvironmentConfig = { + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. `undefined` means "no outbound + * shipping" — the env var is absent, empty, whitespace-only, or + * malformed. There is NO code-side fallback to a shared default; see + * `resolveAnalyticsBaseUrl` below for the rationale. Consumers + * downstream MUST handle the `undefined` case + * (`wireAnalyticsHttpSender` swaps in `DrainingAnalyticsSender`; the + * status snapshot coalesces to `''` and surfaces `(not configured)`). + */ + analyticsBaseUrl: string | undefined authorizationUrl: string billingBaseUrl: string clientId: string @@ -42,6 +53,9 @@ type EnvironmentConfig = { /** * Non-infrastructure config that stays in source (same across envs or not sensitive). + * + * `BRV_ANALYTICS_BASE_URL` is intentionally NOT in this table; see + * `resolveAnalyticsBaseUrl` for the env-only resolution rule. */ const DEFAULTS = { clientId: 'byterover-cli-client', @@ -54,6 +68,46 @@ const DEFAULTS = { const normalizeUrl = (url: string): string => url.replace(/\/+$/, '') +/** + * Resolve `BRV_ANALYTICS_BASE_URL` with no code-side fallback. + * + * - unset (env var missing) -> `undefined` (silent) + * - empty string or whitespace only -> `undefined` (silent) + * - `URL.canParse(value)` rejects -> `undefined` + one warning + * - valid URL -> normalize trailing slash and return + * + * Production builds inject `BRV_ANALYTICS_BASE_URL` at build time. A + * missing env means the build is misconfigured (a fork stripped the + * vars, a CI image dropped them). A code-side fallback to a shared + * upstream endpoint would silently route that build's events to the + * wrong backend — privacy leak and telemetry pollution. Unset and empty + * are silent because they are legitimate states for forks, CI, and + * air-gapped installs; only malformed input (a user-error signal) emits + * a warning. + * + * The second parameter is a test-only seam so unit tests can assert the + * warning surface without touching the `processLog` session-file cache; + * production callers MUST NOT override it. + * + * @internal + */ +export const resolveAnalyticsBaseUrl = ( + raw: string | undefined, + log: (message: string) => void = processLog, +): string | undefined => { + const trimmed = raw?.trim() + if (trimmed === undefined || trimmed === '') return undefined + + if (!URL.canParse(trimmed)) { + log( + `[Environment] BRV_ANALYTICS_BASE_URL is malformed (${JSON.stringify(trimmed)}); remote analytics shipping disabled. Local JSONL tracking continues.`, + ) + return undefined + } + + return normalizeUrl(trimmed) +} + const assertRootDomain = (name: string, url: string): void => { if (new URL(url).pathname !== '/') { throw new Error( @@ -88,7 +142,10 @@ export const getCurrentConfig = (): EnvironmentConfig => { const oidcBase = `${iamBaseUrl}${API_V1_PATH}/oidc` + const analyticsBaseUrl = resolveAnalyticsBaseUrl(process.env.BRV_ANALYTICS_BASE_URL) + return { + analyticsBaseUrl, authorizationUrl: `${oidcBase}/authorize`, billingBaseUrl, clientId: DEFAULTS.clientId, diff --git a/src/server/core/domain/analytics/batch.ts b/src/server/core/domain/analytics/batch.ts new file mode 100644 index 000000000..4a5c9123b --- /dev/null +++ b/src/server/core/domain/analytics/batch.ts @@ -0,0 +1,106 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import type {AnalyticsEvent} from './event.js' +import type {Identity} from './identity.js' + +/** + * An analytics event after identity stamping. This is the unit of work + * that flows through the queue and ultimately ends up on the wire. + */ +export type AnalyticsEventWithIdentity = AnalyticsEvent & Readonly<{identity: Identity}> + +/** + * Wire shape for a batch of analytics events. `schema_version: 2` is the + * only currently-supported value; the backend dispatches on this field to + * route v2 batches (per-event `created_at`) away from the legacy v1 + * (per-event numeric `timestamp`) handler. Coordinate any bump with the + * byterover-telemetry deployment - the v2 handler must be live before a + * CLI that emits v2 ships, or every flush will fail validation and queue + * up to the retry cap. + */ +export type AnalyticsBatchJson = Readonly<{ + events: ReadonlyArray + schema_version: 2 +}> + +/** + * Wire-validation Zod schemas. Used by `fromJson` to deserialize untrusted + * JSON. Zod replaces the previous hand-rolled type guards (which relied on + * `as Record` casts that violate CLAUDE.md's + * "avoid `as Type` assertions" rule). + */ +const IdentityWireSchema = z + .object({ + device_id: z.string().refine((s) => s.trim().length > 0, { + message: 'device_id must be non-empty', + }), + email: z.string().optional(), + name: z.string().optional(), + user_id: z.string().optional(), + }) + // `.strict()` mirrors the event-level schema below: an unexpected field + // nested in `identity` (a forbidden/PII key, or residue from a pre-upgrade + // producer) is rejected at the wire boundary, not silently stripped. + .strict() + +// `.strict()` mirrors the backend's `forbidNonWhitelisted` validator +// (byterover-telemetry PR #21): any residual field from a pre-upgrade +// producer (notably the legacy numeric `timestamp`) must be rejected at the +// wire boundary, not silently stripped. +const AnalyticsEventWithIdentityWireSchema = z + .object({ + created_at: z.string().datetime({offset: true}), + identity: IdentityWireSchema, + name: z.string(), + properties: z.record(z.string(), z.unknown()), + }) + .strict() + +const AnalyticsBatchJsonSchema = z.object({ + events: z.array(AnalyticsEventWithIdentityWireSchema), + schema_version: z.literal(2), +}) + +/** + * A batch of identity-stamped analytics events. Immutable. Constructed + * via `create()` in-process or `fromJson()` at the wire boundary; + * `toJson()` produces the canonical `AnalyticsBatchJson` shape. + */ +export class AnalyticsBatch { + public readonly events: ReadonlyArray + public readonly schema_version: 2 + + private constructor(events: ReadonlyArray) { + this.events = events + this.schema_version = 2 + } + + /** + * Constructs a batch from a list of identity-stamped events. + */ + public static create(events: ReadonlyArray): AnalyticsBatch { + return new AnalyticsBatch(events) + } + + /** + * Deserializes a batch from JSON. Returns `undefined` for any malformed + * input (graceful failure — the caller can drop the batch and log). + */ + public static fromJson(json: unknown): AnalyticsBatch | undefined { + const parsed = AnalyticsBatchJsonSchema.safeParse(json) + if (!parsed.success) return undefined + // Zod's inferred event shape structurally matches AnalyticsEventWithIdentity + // (z.string().optional() is `string | undefined`, equivalent to optional + // properties on Identity). TypeScript widens the inferred mutable shape + // into the Readonly wrapper without an `as` cast. + return new AnalyticsBatch(parsed.data.events) + } + + /** + * Serializes the batch to its wire shape. + */ + public toJson(): AnalyticsBatchJson { + return {events: this.events, schema_version: this.schema_version} + } +} diff --git a/src/server/core/domain/analytics/event.ts b/src/server/core/domain/analytics/event.ts new file mode 100644 index 000000000..7d6745841 --- /dev/null +++ b/src/server/core/domain/analytics/event.ts @@ -0,0 +1,16 @@ +/** + * Internal analytics event shape, before identity stamping. This is the wire- + * bound event type: `AnalyticsBatch.events` carries `AnalyticsEventWithIdentity` + * values, which extend this shape with `identity`. + * + * `created_at` is the wire timestamp: a strict ISO 8601 string with a + * timezone designator (e.g. `2026-05-28T21:32:11+07:00` or `...Z`). The + * local-only numeric sort key (`timestamp` on `StoredAnalyticsRecord`) + * lives only on disk and never crosses the wire — see + * `src/shared/analytics/stored-record.ts`. + */ +export type AnalyticsEvent = Readonly<{ + created_at: string + name: string + properties: Record +}> diff --git a/src/server/core/domain/analytics/identity.ts b/src/server/core/domain/analytics/identity.ts new file mode 100644 index 000000000..74ef818a3 --- /dev/null +++ b/src/server/core/domain/analytics/identity.ts @@ -0,0 +1,16 @@ + + +/** + * Wire-format identity attached to every analytics event. `device_id` is + * always present (M1.1 invariant); `user_id` / `email` / `name` are only + * present when the user is authenticated. + * + * Snake_case on the wire per the analytics spec; this is the only + * identity shape — no internal camelCase variant exists. + */ +export type Identity = Readonly<{ + device_id: string + email?: string + name?: string + user_id?: string +}> diff --git a/src/server/core/domain/client/client-info.ts b/src/server/core/domain/client/client-info.ts index 90b48d87c..ef91ba34c 100644 --- a/src/server/core/domain/client/client-info.ts +++ b/src/server/core/domain/client/client-info.ts @@ -54,6 +54,14 @@ export class ClientInfo { public readonly type: ClientType /** Mutable: set via setAgentName() for MCP clients after MCP initialize handshake */ private _agentName: string | undefined + /** + * M15.8: frozen copy of the IDE name as emitted on `mcp_session_start`. + * Read by `mcp_session_ended` so the start/end pair always carries + * matching `client_name` even if `_agentName` were re-mutated mid-session. + * Also serves as the "session active for analytics" gate — non-undefined + * iff a `mcp_session_start` has been emitted for this ClientInfo. + */ + private _mcpSessionEmittedName: string | undefined /** Mutable: set via associateProject() for global-scope MCP clients */ private _projectPath: string | undefined @@ -88,6 +96,16 @@ export class ClientInfo { return this.type !== 'agent' } + /** + * M15.8: the `client_name` value emitted on the prior `mcp_session_start`, + * or undefined if no session-start has fired for this ClientInfo yet. + * Read by ClientManager's `mcp_session_ended` emitter so start/end pairs + * remain correlated even if `agentName` were re-mutated mid-session. + */ + get mcpSessionEmittedName(): string | undefined { + return this._mcpSessionEmittedName + } + /** * The project this client is associated with. * Undefined for global-scope MCP clients that haven't been associated yet. @@ -104,6 +122,16 @@ export class ClientInfo { this._projectPath = projectPath } + /** + * M15.8: freeze the IDE name that `mcp_session_start` was just emitted with. + * Called immediately before `analyticsClient.track('mcp_session_start')` + * in ClientManager. The matching `mcp_session_ended` reads this value + * instead of the live `agentName` to guarantee start/end correlation. + */ + markMcpSessionStartEmitted(emittedName: string): void { + this._mcpSessionEmittedName = emittedName + } + /** * Set the agent name for this MCP client. * Called after MCP initialize handshake provides clientInfo. diff --git a/src/server/core/domain/entities/global-config.ts b/src/server/core/domain/entities/global-config.ts index 527fdc2f5..1b7656353 100644 --- a/src/server/core/domain/entities/global-config.ts +++ b/src/server/core/domain/entities/global-config.ts @@ -4,14 +4,32 @@ import {GLOBAL_CONFIG_VERSION} from '../../../constants.js' * Parameters for creating a GlobalConfig instance. */ export interface GlobalConfigParams { + readonly analytics: boolean + readonly deviceId: string + readonly version: string +} + +/** + * Serialized JSON shape for GlobalConfig. + */ +export type GlobalConfigJson = { + readonly analytics: boolean + readonly deviceId: string + readonly version: string +} + +type GlobalConfigJsonInput = { + readonly analytics?: boolean readonly deviceId: string readonly version: string } /** * Type guard for GlobalConfig JSON validation. + * `analytics` is optional on input (legacy configs predate the field); when + * present it must be a boolean. */ -const isGlobalConfigJson = (json: unknown): json is GlobalConfigParams => { +const isGlobalConfigJson = (json: unknown): json is GlobalConfigJsonInput => { if (typeof json !== 'object' || json === null || json === undefined) return false const obj = json as Record @@ -24,6 +42,10 @@ const isGlobalConfigJson = (json: unknown): json is GlobalConfigParams => { return false } + if ('analytics' in obj && typeof obj.analytics !== 'boolean') { + return false + } + return true } @@ -32,16 +54,19 @@ const isGlobalConfigJson = (json: unknown): json is GlobalConfigParams => { * Contains device-level settings that persist across all projects. */ export class GlobalConfig { + public readonly analytics: boolean public readonly deviceId: string public readonly version: string private constructor(params: GlobalConfigParams) { this.deviceId = params.deviceId this.version = params.version + this.analytics = params.analytics } /** * Creates a new GlobalConfig with the given device ID and current version. + * Analytics defaults to `false` (opt-in). * * @param deviceId - The unique device identifier (UUID v4) * @returns A new GlobalConfig instance @@ -53,6 +78,7 @@ export class GlobalConfig { } return new GlobalConfig({ + analytics: false, deviceId, version: GLOBAL_CONFIG_VERSION, }) @@ -61,6 +87,8 @@ export class GlobalConfig { /** * Deserializes config from JSON format. * Returns undefined for invalid JSON structure (graceful failure). + * Missing `analytics` defaults to `false` to preserve the opt-in promise + * across upgrades from pre-analytics builds. * * @param json - The JSON object to deserialize * @returns GlobalConfig instance or undefined if invalid @@ -70,16 +98,56 @@ export class GlobalConfig { return undefined } - return new GlobalConfig(json) + return new GlobalConfig({ + analytics: json.analytics ?? false, + deviceId: json.deviceId, + version: json.version, + }) } /** * Serializes the config to JSON format. */ - public toJson(): Record { + public toJson(): GlobalConfigJson { return { + analytics: this.analytics, deviceId: this.deviceId, version: this.version, } } + + /** + * Returns a new GlobalConfig with the analytics flag set to the given value. + * deviceId and version are preserved. The original instance is not mutated. + * + * @param value - The new analytics value + * @returns A new GlobalConfig instance + */ + public withAnalytics(value: boolean): GlobalConfig { + return new GlobalConfig({ + analytics: value, + deviceId: this.deviceId, + version: this.version, + }) + } + + /** + * Returns a new GlobalConfig with the deviceId replaced. The analytics flag + * and version are preserved. The original instance is not mutated. + * + * @param deviceId - The new device identifier (must be non-empty) + * @returns A new GlobalConfig instance + * @throws Error if deviceId is empty or whitespace-only + */ + public withDeviceId(deviceId: string): GlobalConfig { + if (deviceId.trim().length === 0) { + throw new Error('Device ID cannot be empty') + } + + return new GlobalConfig({ + analytics: this.analytics, + deviceId, + version: this.version, + }) + } } diff --git a/src/server/core/domain/entities/settings.ts b/src/server/core/domain/entities/settings.ts index 11a11bfe3..aa4e5557a 100644 --- a/src/server/core/domain/entities/settings.ts +++ b/src/server/core/domain/entities/settings.ts @@ -1,3 +1,4 @@ +import {ANALYTICS_ENABLED_KEY} from '../../../../shared/constants/settings-keys.js' import { AGENT_LLM_ITERATION_BUDGET_MS, AGENT_LLM_REQUEST_TIMEOUT_MS, @@ -12,7 +13,7 @@ import { * and TUI render output (uppercased). Web docs / WebUI consume this * field to render the same groupings independently of key naming. */ -export type SettingCategory = 'concurrency' | 'llm' | 'task-history' | 'updates' +export type SettingCategory = 'analytics' | 'concurrency' | 'llm' | 'task-history' | 'updates' /** * Value-kind for dispatch between the duration formatter / parser @@ -35,37 +36,75 @@ type BaseSettingDescriptor = { readonly restartRequired: boolean } +/** + * Where the writable value's canonical storage lives. + * + * - `'file'` (default, omitted on most descriptors) — persisted to + * `/settings.json` via `FileSettingsStore`. + * - `'global-config'` — persisted to `/config.json` via + * `GlobalConfigHandler`. The settings handler routes GET/SET/RESET/LIST + * for these keys through the global-config facade; the file store + * refuses to persist them. + * + * Read-only descriptors (`readonly-info`) are never persisted and do + * not carry this field. + */ +export type SettingStorage = 'file' | 'global-config' + export type IntegerSettingDescriptor = BaseSettingDescriptor & { readonly default: number readonly max: number readonly min: number + readonly storage?: SettingStorage readonly type: 'integer' readonly unit?: SettingUnit } export type BooleanSettingDescriptor = BaseSettingDescriptor & { readonly default: boolean + readonly storage?: SettingStorage readonly type: 'boolean' } /** - * Descriptor for a single user-configurable setting. Discriminated on - * `type` so consumers narrow with a single check before reading - * type-specific fields (`min`/`max` on integers, etc). + * Descriptor for a read-only operational snapshot key (e.g. `analytics.status`). + * Carries no default, refuses `set` / `reset`, and is never persisted to + * `settings.json`. The live value is supplied by an info provider injected + * into `SettingsHandler` at construction time — descriptors stay pure data + * so the registry never crosses the `core/domain -> infra` import boundary. + * + * `restartRequired` is pinned to literal `false` because a snapshot can + * never demand a daemon restart: there is no override to apply. + */ +export type ReadonlyInfoSettingDescriptor = BaseSettingDescriptor & { + readonly restartRequired: false + readonly type: 'readonly-info' +} + +/** + * Descriptor for a single registered setting. Discriminated on `type` so + * consumers narrow with a single check before reading type-specific + * fields (`min`/`max` on integers, `default` on writable variants, etc). * - * Defaults reference the existing constants module so a constant change - * automatically updates the setting's default. + * Defaults on writable variants reference the existing constants module + * so a constant change automatically updates the setting's default. */ -export type SettingDescriptor = BooleanSettingDescriptor | IntegerSettingDescriptor +export type SettingDescriptor = + | BooleanSettingDescriptor + | IntegerSettingDescriptor + | ReadonlyInfoSettingDescriptor /** * View of one setting: the key, the user's current override (or the default * if none is set), and the registered default. Carries the union of value * shapes; consumers narrow on the corresponding descriptor's `type`. + * + * Readonly-info keys carry no `default` (snapshots have no default state) + * and may carry a structured `current` payload supplied by the provider. */ export type SettingItem = { - readonly current: boolean | number - readonly default: boolean | number + readonly current: boolean | number | Readonly> | undefined + readonly default?: boolean | number readonly key: string readonly restartRequired: boolean } @@ -79,6 +118,8 @@ export type SettingItem = { export const SETTINGS_KEYS = { AGENT_POOL_MAX_CONCURRENT_TASKS: 'agentPool.maxConcurrentTasksPerProject', AGENT_POOL_MAX_SIZE: 'agentPool.maxSize', + ANALYTICS_ENABLED: ANALYTICS_ENABLED_KEY, + ANALYTICS_STATUS: 'analytics.status', LLM_ITERATION_BUDGET_MS: 'llm.iterationBudgetMs', LLM_REQUEST_TIMEOUT_MS: 'llm.requestTimeoutMs', TASK_HISTORY_MAX_ENTRIES: 'taskHistory.maxEntries', @@ -146,6 +187,22 @@ export const SETTINGS_REGISTRY: readonly SettingDescriptor[] = [ restartRequired: false, type: 'boolean', }, + { + category: 'analytics', + description: 'Live analytics shipping snapshot (queue, last flush, backoff, endpoint)', + key: SETTINGS_KEYS.ANALYTICS_STATUS, + restartRequired: false, + type: 'readonly-info', + }, + { + category: 'analytics', + default: false, + description: 'Send anonymous telemetry to ByteRover. Local tracking is always on.', + key: SETTINGS_KEYS.ANALYTICS_ENABLED, + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, ] export function findSettingDescriptor(key: string): SettingDescriptor | undefined { diff --git a/src/server/core/domain/knowledge/markdown-writer.ts b/src/server/core/domain/knowledge/markdown-writer.ts index 414dddc9d..d6bd9d8e8 100644 --- a/src/server/core/domain/knowledge/markdown-writer.ts +++ b/src/server/core/domain/knowledge/markdown-writer.ts @@ -199,8 +199,13 @@ function generateFrontmatter( /** * Parse YAML frontmatter from markdown content. * Returns null if no frontmatter is found (backward compat with old format). + * + * Exported for cross-module reuse by daemon-side telemetry harvest + * (M12.3: AnalyticsHook reads frontmatter from affected files post-op + * to populate per-event `tags` / `keywords` / `related` arrays). Existing + * in-file callers are unaffected by the export-keyword addition. */ -function parseFrontmatter(content: string): null | ParsedFrontmatter { +export function parseFrontmatter(content: string): null | ParsedFrontmatter { if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) { return null } diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index 2f9e3b928..8af12c076 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -733,6 +733,12 @@ export const LlmResponseEventSchema = z.object({ export const LlmToolCallEventSchema = z.object({ args: z.record(z.unknown()), callId: z.string().optional(), + // PR #728 review nit: parity with `LlmToolResultEventSchema` so M17 + // synthetic emits can stamp `metadata._synthetic = true` on toolCalls + // too — same marker the broadcast-skip guard in `TaskRouter.routeLlmEvent` + // reads. Without this, a future `.strict()` migration would silently drop + // the marker on toolCall envelopes. + metadata: z.record(z.unknown()).optional(), sessionId: z.string(), taskId: z.string(), toolName: z.string(), diff --git a/src/server/core/domain/transport/task-info.ts b/src/server/core/domain/transport/task-info.ts index 131eae20d..4796d4b63 100644 --- a/src/server/core/domain/transport/task-info.ts +++ b/src/server/core/domain/transport/task-info.ts @@ -1,4 +1,5 @@ import type {ReasoningContentItem, ToolCallEvent} from '../../../../shared/transport/events/task-events.js' +import type {ClientType} from '../client/client-info.js' import type {TaskErrorData, TaskListItemStatus, TaskType} from './schemas.js' /** @@ -14,6 +15,21 @@ export type TaskInfo = { /** Client's working directory for file validation */ clientCwd?: string clientId: string + /** + * M15.8: snapshot of submitting client's IDE product name (e.g. "Cursor") + * captured at handleTaskCreate. Used by AnalyticsHook to emit + * mcp_tool_called with `client_name` even after the originating MCP + * client disconnects mid-task. Undefined for non-MCP submissions or + * when the MCP handshake had not delivered a name by task-create time. + */ + clientName?: string + /** + * M15.8: snapshot of submitting client's transport kind. Lets + * AnalyticsHook gate MCP-only emits on `clientType === 'mcp'` + * without re-querying ClientManager (which may have disconnected + * the client by task completion). + */ + clientType?: ClientType /** Set when task reaches a terminal state */ completedAt?: number content: string diff --git a/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts new file mode 100644 index 000000000..c7fb6b394 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts @@ -0,0 +1,69 @@ +/** + * M4.5 failure-resilience policy for the analytics flush scheduler. + * + * Pure in-memory state — no persistence. A daemon restart starts from + * the base interval; passive failure tracking is the goal, not exact + * accounting across restarts. + * + * Backoff schedule: `30s → 60s → 2m → 5m`, cap at `5m`. First success + * resets to `30s`. The schedule lives inside the implementation; this + * interface only exposes the next effective delay and the state-mutation + * callbacks. + * + * The reachability state (healthy / degraded / unreachable) used by + * `brv settings get analytics.status` (M4.6) is DERIVED from `consecutiveFailures()` + * by the caller, not exposed here. Mapping (M4.6 owns the labels): + * - 0 failures → healthy + * - 1-2 failures → degraded + * - 3+ failures → unreachable + */ +export interface IAnalyticsBackoffPolicy { + /** + * M5.4 (ENG-2658): honor a server-supplied retry delay (HTTP 429 + * `Retry-After`, or a 503 from the nginx edge backstop). Overrides the + * next effective delay with `max(retryAfterMs, scheduled delay)` so a + * misbehaving server can never pull retries below the safe minimum, and + * marks the policy rate-limited (see `isRateLimited()`). Does NOT advance + * `consecutiveFailures()` — a throttled endpoint is reachable, not failing, + * so it must never tip the daemon into the "unreachable" reachability band. + * Cleared by the next `onSuccess()` or `onFailure()`. + */ + applyServerHint(retryAfterMs: number): void + + /** + * Number of failures since the last `onSuccess()`. Unbounded — used + * by M4.6 to classify reachability beyond the backoff cap (a daemon + * that has been offline for hours should display "unreachable", not + * just "delay capped at 5m"). + */ + consecutiveFailures(): number + + /** + * M5.4 (ENG-2658): true while the last flush outcome was a server-driven + * rate-limit (429/503) and no success/failure has cleared it since. The + * M4.6 status snapshot maps this to a distinct `rate_limited` reachability + * state so on-call can tell throttling apart from an unreachable backend. + */ + isRateLimited(): boolean + + /** + * Effective next-tick delay in milliseconds. Reading this method is + * pure: it does NOT advance the schedule. Callers should treat the + * value as live (read at arm-time) so a concurrent success-or-failure + * between two reads picks up correctly. + */ + nextDelayMs(): number + + /** + * Record a transient failure (HTTP 5xx, timeout, network). Advances + * the schedule one step, up to the cap. `http_4xx` is a payload-shape + * problem, not a transient signal — callers MUST NOT call this for 4xx. + */ + onFailure(): void + + /** + * Record a successful flush. Resets the schedule and the consecutive + * counter to zero immediately, regardless of prior peak. + */ + onSuccess(): void +} diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts new file mode 100644 index 000000000..4bd550183 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -0,0 +1,78 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {AnalyticsBatch} from '../../domain/analytics/batch.js' + +/** + * Consumer-facing analytics tracking contract. Every consumer surface + * (TUI, oclif commands, MCP server, webui, agent processes) ultimately + * routes events into an implementation of this interface inside the + * daemon. Implementations are responsible for identity resolution, + * super-property stamping, and queueing; consumers just call `track()`. + * + * `track()` is typed against the discriminated union catalog + * (`AnyAnalyticsEvent` in `shared/analytics/events/index.ts`): magic-string + * typos and wrong-shape payloads become compile errors. Adding a new event + * requires registering it in the catalog first; emit sites then become + * statically checked. + */ +export interface IAnalyticsClient { + /** + * Cancel any in-flight `flush()`'s HTTP request. M4.4: invoked by + * `GlobalConfigHandler` when `brv settings set analytics.share false` flips the flag + * so the daemon doesn't half-ship a batch across an enable/disable + * boundary. No-op when no flush is in flight. + */ + abort: () => void + + /** + * Drains the queue and returns the events as a serializable batch. + * Used by the network sender (M4) and by tests. + */ + flush: () => Promise + + /** + * M4.6: client-owned runtime state for the `brv settings get analytics.status` + * command. Returns the timestamp of the last successful flush (or + * `undefined` if none has run this daemon-lifetime), the count of + * records currently pending in JSONL, and the cumulative count of + * events dropped by the in-memory queue's drop-oldest cap. + * + * Async because `queueDepth` reads JSONL — that's the authoritative + * "waiting to ship" metric. The in-memory queue mirror caps at 1000 + * via drop-oldest and would mislead operators. + */ + getRuntimeState: () => Promise<{ + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number + }> + + /** + * Notify the client that the daemon-wide auth state transitioned + * (login, logout, account switch, token revoked). + * + * M4.1 contract: every pending and historical event in the JSONL queue + * MUST be dropped, plus the in-memory mirror queue cleared. This + * preserves the invariant that every event waiting to flush was + * tracked under the current auth state. Without this drop, a batch + * flushed across a transition would mix two sessions' identities and + * the backend (which trusts per-event identity) would attribute past + * events to the new session — or vice-versa. + * + * Errors are swallowed: analytics MUST NOT crash a consumer. A + * disk-write failure during clear is logged best-effort but never + * propagates. + */ + onAuthTransition: () => Promise + + /** + * Records an analytics event. When the analytics flag is disabled the + * call must be a true no-op (no allocations, no resolver calls). + * + * The generic `` plus `PropsArg` rest + * tuple force callers to pick a registered event name and supply a + * matching property shape. Events with no required properties (e.g. + * `daemon_start`) allow the properties argument to be omitted. + */ + track: (event: E, ...rest: PropsArg) => void +} diff --git a/src/server/core/interfaces/analytics/i-analytics-http-client.ts b/src/server/core/interfaces/analytics/i-analytics-http-client.ts new file mode 100644 index 000000000..3452a987e --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-http-client.ts @@ -0,0 +1,80 @@ +import type {AnalyticsBatch} from '../../domain/analytics/batch.js' + +/** + * Per-request headers stamped onto every analytics POST. + * + * `deviceId` is mandatory — the backend's IdentityResolverGuard rejects + * anonymous batches without it. `sessionId` is the per-request session + * token (M3.4 backwards-compat hint); the authoritative per-event identity + * lives inside each event after M4.1. + * + * `userAgent` follows the `brv-cli/` convention; the impl + * stamps it so backend logs can correlate by CLI version. + */ +export type AnalyticsHttpHeaders = Readonly<{ + deviceId: string + sessionId?: string + userAgent: string +}> + +/** + * Outcome of a single send attempt. Tagged-union so the caller + * (`HttpAnalyticsSender`) can classify the failure mode for the M4.5 + * backoff policy and the M4.6 status command without re-parsing + * arbitrary error objects. + * + * Reasons: + * - `timeout` — request exceeded the 5 second budget. + * - `http_4xx` — backend rejected the payload (validation, auth, etc). + * - `http_5xx` — backend error (non-503); eligible for backoff retry. + * - `network` — connection refused / DNS / TLS / abort before response. + * - `rate_limited` — M5.4 (ENG-2658): the app throttler returned 429, or the + * nginx edge backstop returned 503. `retryAfterMs` carries the delay the + * caller must honor (server `Retry-After` header, then a `retry_after_seconds` + * body field, then a 60s default when the server supplies neither). + * + * `status` is populated for the HTTP-status paths so the caller can log the + * exact code. `retryAfterMs` is present only on the `rate_limited` variant. + */ +export type AnalyticsHttpSendResult = + | Readonly<{ + ok: false + reason: 'http_4xx' | 'http_5xx' | 'network' | 'timeout' + status?: number + }> + | Readonly<{ + ok: false + reason: 'rate_limited' + retryAfterMs: number + status?: number + }> + | Readonly<{ok: true}> + +/** + * Daemon-side HTTP transport for analytics batches. Single attempt per + * call, no retries (M4.5 owns retry/backoff); 5 second timeout. + * + * MUST NOT throw — every failure path returns a structured + * `AnalyticsHttpSendResult`. Analytics MUST NOT crash the daemon. + * + * Implementations: + * - `AxiosAnalyticsHttpClient` — production transport over axios. + * - In-process fakes in unit tests for offline assertion. + */ +/** + * Optional per-call controls. `signal` is the M4.4 cancellation hook + * used by `brv settings set analytics.share false` (and by the daemon shutdown path) to + * abort an in-flight send so the daemon doesn't half-ship a batch + * across an enable/disable boundary. + */ +export type AnalyticsHttpSendOptions = Readonly<{ + signal?: AbortSignal +}> + +export interface IAnalyticsHttpClient { + send: ( + batch: AnalyticsBatch, + headers: AnalyticsHttpHeaders, + options?: AnalyticsHttpSendOptions, + ) => Promise +} diff --git a/src/server/core/interfaces/analytics/i-analytics-queue.ts b/src/server/core/interfaces/analytics/i-analytics-queue.ts new file mode 100644 index 000000000..6da1a7637 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-queue.ts @@ -0,0 +1,38 @@ +import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-record.js' + +/** + * In-memory queue contract for identity-stamped analytics records. + * Implementations enforce a configurable cap with drop-oldest semantics + * and track a cumulative dropped count for later observability. + * + * Carries `StoredAnalyticsRecord` (with `id`/`status`/`attempts` local + * metadata) since M9.3 — JSONL is the durable source of truth and the + * queue is a fast in-memory mirror. M10.2's `flush()` reads from JSONL + * (not this queue), so any drop-oldest eviction here is recoverable. + */ +export interface IAnalyticsQueue { + /** + * Drains the queue and returns the records in FIFO order. Caller takes + * ownership; the queue is empty afterwards. `droppedCount()` is NOT + * reset by this call. + */ + drain: () => StoredAnalyticsRecord[] + + /** + * Returns the cumulative number of records dropped due to the cap + * across the queue's lifetime. Never reset. + */ + droppedCount: () => number + + /** + * Pushes a record onto the queue. If the queue is at capacity, the + * oldest record is dropped to make room and `droppedCount()` is + * incremented. + */ + push: (record: StoredAnalyticsRecord) => void + + /** + * Returns the current number of records in the queue. + */ + size: () => number +} diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts new file mode 100644 index 000000000..97500d3f9 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -0,0 +1,74 @@ +import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-record.js' + +/** + * Classification of a failure mode, surfaced by `HttpAnalyticsSender` so + * `AnalyticsClient` can feed it into the M4.5 backoff policy. + * + * - `timeout` - request exceeded the 5s budget. Transient → back off. + * - `network` - connection refused / DNS / TLS / aborted. Transient → back off. + * - `http_5xx` - server error. Transient → back off. + * - `http_4xx` - backend rejected the payload shape. NOT transient — the + * caller MUST NOT advance backoff on 4xx; retrying won't help. + * - `rate_limited` - M5.4 (ENG-2658): server throttle (429) or nginx edge + * backstop (503). The caller honors the paired `retryAfterMs` via + * `backoffPolicy.applyServerHint` and MUST NOT advance the failure counter + * (a throttled endpoint is reachable, not failing). + */ +export type SendFailureReason = 'http_4xx' | 'http_5xx' | 'network' | 'rate_limited' | 'timeout' + +/** + * Per-send outcome. Each input record's `id` is mirrored back in exactly + * one of `succeeded` / `failed`; M10.2's flush wiring will then translate + * those id arrays into `JsonlAnalyticsStore.updateStatus` calls. + * + * Both arrays empty is a valid result and is what `NoOpAnalyticsSender` + * returns — it leaves JSONL state untouched ("nothing was processed"). + */ +export type SendResult = Readonly<{ + failed: string[] + /** + * Present only when `failed.length > 0`. Absent on success and on + * empty-batch no-op calls. Callers that don't care about backoff + * (no-op senders, tests) may continue to ignore this field. + */ + reason?: SendFailureReason + /** + * M5.4 (ENG-2658): server-supplied retry delay in milliseconds. Present + * only alongside `reason: 'rate_limited'`; the caller feeds it to + * `backoffPolicy.applyServerHint`. + */ + retryAfterMs?: number + succeeded: string[] +}> + +/** + * Per-send options. `signal` is the M4.4 cancellation hook so the + * AnalyticsClient can abort an in-flight send when `brv analytics + * disable` fires. + */ +export type AnalyticsSenderOptions = Readonly<{ + signal?: AbortSignal +}> + +/** + * Daemon-side sender contract. M10.2's `AnalyticsClient.flush` invokes + * `send()` with a snapshot of pending JSONL rows; the sender's only + * responsibility is to attempt transmission and return the per-record + * outcome as id arrays. + * + * Implementations: + * - `HttpAnalyticsSender` (M4.2, production default): serializes records to + * the wire format and POSTs the batch to the telemetry backend. + * - `NoOpAnalyticsSender`: semantically inert (`{succeeded: [], failed: []}`). + * Test seam — used to assert the M10.2 "leave-JSONL-untouched" invariant + * without going through the real transport. + */ +export interface IAnalyticsSender { + /** + * Attempts to ship `records`. Returns the per-record outcome as id arrays. + * MUST NOT throw — analytics MUST NOT crash the daemon. Implementations + * that hit a transient error (network failure, 5xx) should classify + * those records as `failed` and let M9.2's retry-cap policy handle them. + */ + send: (records: readonly StoredAnalyticsRecord[], options?: AnalyticsSenderOptions) => Promise +} diff --git a/src/server/core/interfaces/analytics/i-identity-resolver.ts b/src/server/core/interfaces/analytics/i-identity-resolver.ts new file mode 100644 index 000000000..00307189b --- /dev/null +++ b/src/server/core/interfaces/analytics/i-identity-resolver.ts @@ -0,0 +1,28 @@ +import type {Identity} from '../../domain/analytics/identity.js' +import type {AuthToken} from '../../domain/entities/auth-token.js' + +/** + * Minimal consumer-side view of the auth state store that the identity + * resolver needs. Defined here next to the consumer (not in the auth + * module) per CLAUDE.md "interfaces at the consumer". + * + * The full auth state store has additional methods (loadToken, + * onAuthChanged, etc.); the resolver only needs synchronous access to + * the current cached token. + */ +export interface IAuthStateReader { + getToken: () => AuthToken | undefined +} + +/** + * Resolves the per-event analytics Identity. Each `resolve()` call reads + * the current auth + global config state so auth-state transitions + * mid-batch are observable to consumers (M2.5 stamps identity per + * `track()` call). + * + * Async because `device_id` is sourced from `IGlobalConfigStore` which + * is itself async; matches the M2.3 super-properties precedent. + */ +export interface IIdentityResolver { + resolve: () => Promise +} diff --git a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts new file mode 100644 index 000000000..7c48c64bb --- /dev/null +++ b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts @@ -0,0 +1,130 @@ +import type {StoredAnalyticsRecord, StoredStatus} from '../../../../shared/analytics/stored-record.js' + +/** + * Filter and pagination options for `list()`. + * + * `offset >= 0`, `limit >= 1`. Caller validates bounds (M11.1 transport + * schema enforces `limit 1..200`); the store does not re-validate. + */ +export type JsonlAnalyticsStoreListOptions = { + eventName?: string + limit: number + offset: number + status?: StoredStatus +} + +/** + * Result of `list()`. `total` is the post-filter row count (NOT total file + * rows), so a UI can render "showing X-Y of total" correctly. + */ +export type JsonlAnalyticsStoreListResult = Readonly<{ + rows: StoredAnalyticsRecord[] + total: number +}> + +/** + * The two terminal/transitional statuses callers may write. `'pending'` is + * the implicit initial state set by `append()` and is NEVER a valid input + * to `updateStatus`. + */ +export type JsonlAnalyticsStoreUpdateStatus = 'failed' | 'sent' + +/** + * Daemon-side durable JSONL store for analytics records (M9.2). + * + * Contract: + * - `append` is the only producer; new rows always start at + * `status='pending', attempts=0`. + * - `updateStatus(ids, 'sent')` flips to terminal `'sent'` (no attempts + * change). + * - `updateStatus(ids, 'failed')` is the **retry-cap gate**: increments + * `attempts` and only transitions to terminal `'failed'` once + * `attempts >= MAX_ATTEMPTS`; otherwise the row stays at `'pending'` + * so the next flush retries. Callers do NOT branch on the cap. + * - `loadPending()` returns rows at `status='pending'` only (which under + * the cap policy includes both fresh `attempts=0` rows and in-flight + * `attempts=1..MAX_ATTEMPTS-1` retries). + * - `list()` paginates with optional filters; sort order is + * `(timestamp DESC, id DESC)`. + * - All mutating calls (`append`, `updateStatus`) serialize through a + * single in-process Promise chain on the store instance — concurrent + * `append` and `updateStatus` cannot lose rows. + * - File-size cap with drop-oldest-sent-first compaction. Pending and + * failed rows are never dropped by compaction. + */ +export interface IJsonlAnalyticsStore { + /** + * Append a new record (`status='pending', attempts=0`) to the JSONL + * file with fsync. If the file-size cap would be exceeded, oldest + * `'sent'` rows are dropped first; if dropping every available `'sent'` + * row still leaves the file over cap, the append throws + * `JsonlCapFullError` after incrementing `droppedFullCount()`. + * + * The throw is the only signal callers have that the record did NOT land + * on disk — needed so the in-memory mirror queue (`IAnalyticsQueue`) does + * not push a record that JSONL never persisted (JSONL=truth invariant). + * Callers that don't care MUST still catch: analytics MUST NOT crash + * the consumer. + */ + append: (record: StoredAnalyticsRecord) => Promise + + /** + * Truncate the JSONL file: drop every row regardless of status. + * + * M4.1: invoked when AuthStateStore reports a login/logout transition. + * Pending events tracked under the prior session must NOT be flushed + * under the new session's identity. Clearing on transition guarantees + * the queue is homogeneous per session, every event in the queue at + * flush time was tracked under the current auth state. + * + * Concurrency: serializes through the same write chain as `append` / + * `updateStatus`, so an in-flight append finishes before clear runs and + * a clear in progress blocks subsequent appends. Atomic via tmp+rename. + * + * Counters (`droppedFullCount`, `droppedSentCount`) are NOT reset, + * they're cumulative lifetime stats surfaced by `brv settings get analytics.status`. + */ + clear: () => Promise + + /** + * Cumulative count of `append` calls dropped because the cap was full + * with no `'sent'` rows to evict (file saturated with pending+failed). + * Never reset; surfaced for `brv settings get analytics.status` (M4.6). + */ + droppedFullCount: () => number + + /** + * Cumulative count of `'sent'` rows dropped by compaction across the + * store's lifetime. Never reset; surfaced for `brv settings get analytics.status` + * (M4.6). + */ + droppedSentCount: () => number + + /** + * Read paginated, filtered rows. Sort order is + * `(timestamp DESC, id DESC)`. `total` is the post-filter row count. + * Returns empty result when the file does not exist yet. + */ + list: (opts: JsonlAnalyticsStoreListOptions) => Promise + + /** + * Read all rows currently at `status='pending'`. Used by M10.2's + * `flush()` as the source-of-truth for what to ship next. Returns + * empty array when the file does not exist yet. + */ + loadPending: () => Promise + + /** + * Mirror a per-record send result back to disk. + * + * `'sent'`: flip `status` to `'sent'`. `attempts` unchanged. + * + * `'failed'`: increment `attempts`. If `attempts >= MAX_ATTEMPTS` the + * row transitions to terminal `status='failed'`; otherwise stays at + * `status='pending'` (next flush retries). A `'failed'` update on a + * row already at terminal `status='failed'` is a no-op (no overshoot). + * + * Empty `ids` array is a no-op. Non-matching ids are silently ignored. + */ + updateStatus: (ids: readonly string[], status: JsonlAnalyticsStoreUpdateStatus) => Promise +} diff --git a/src/server/core/interfaces/analytics/i-super-properties-resolver.ts b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts new file mode 100644 index 000000000..bf0a8c8f2 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts @@ -0,0 +1,30 @@ + +import type {ClientType} from '../../domain/client/client-info.js' + +/** + * Super properties stamped onto every analytics event. Wire-format + * snake_case throughout. `device_id` is sourced from `GlobalConfig`; + * the remaining four are static across the daemon's lifetime. + * + * `client_kind` is stamped when the analytics emit originates from a + * Socket.IO transport call wrapped in `clientKindContext.run()`. Absent + * when the emit happens outside any context wrap (daemon-internal track + * or agent-fork connection). + */ +export type SuperProperties = Readonly<{ + cli_version: string + client_kind?: ClientType + device_id: string + environment: 'development' | 'production' + node_version: string + os: NodeJS.Platform +}> + +/** + * Resolves the five super properties for analytics events. + * `resolve()` is async because `device_id` is sourced from + * `IGlobalConfigStore.read()` which is itself async. + */ +export interface ISuperPropertiesResolver { + resolve: () => Promise +} diff --git a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts index c39b72e56..4248361fa 100644 --- a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts +++ b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts @@ -34,6 +34,11 @@ export interface ITaskLifecycleHook { * implement it. Implementations must never throw. */ onTaskUpdate?(task: TaskInfo): Promise - /** Called when an LLM tool result event is received for an ACTIVE task (not grace-period). */ - onToolResult?(taskId: string, payload: LlmToolResultEvent): void + /** + * Called when an LLM tool result event is received for an ACTIVE task (not grace-period). + * Now async so implementations (e.g. AnalyticsHook) can do non-blocking I/O without + * wedging the daemon event loop. TaskRouter awaits the returned promise inside its + * per-hook try/catch so rejections cannot escape as unhandled rejections. + */ + onToolResult?(taskId: string, payload: LlmToolResultEvent): Promise } diff --git a/src/server/core/interfaces/state/i-auth-state-store.ts b/src/server/core/interfaces/state/i-auth-state-store.ts index e2262e038..019eea51a 100644 --- a/src/server/core/interfaces/state/i-auth-state-store.ts +++ b/src/server/core/interfaces/state/i-auth-state-store.ts @@ -7,6 +7,22 @@ import type {AuthToken} from '../../domain/entities/auth-token.js' */ export type AuthChangedCallback = (token: AuthToken | undefined) => void +/** + * Callback fired BEFORE the cached auth token is mutated. Listeners + * can read `getToken()` and observe the OLD token, which is the + * critical guarantee for M4.4's auth-transition force-flush: the + * analytics client needs to flush events under the OLD session header + * before the new token replaces the cache. + * + * Listeners are awaited in registration order. A listener that hangs + * is bounded by `beforeAuthChangeTimeoutMs` (default 6s) so a wedged + * subsystem cannot deadlock the auth transition. + */ +export type BeforeAuthChangedCallback = ( + oldToken: AuthToken | undefined, + newToken: AuthToken | undefined, +) => Promise | void + /** * Callback fired when auth token has expired. * Separate from AuthChanged because an expired token is still "present" — @@ -45,7 +61,9 @@ export interface IAuthStateStore { * Register a callback for auth state changes. * Fired when: login (new token), token refresh (changed token), logout (undefined). * - * Only one callback supported — subsequent calls overwrite previous. + * Multiple callbacks supported. Listeners fire in registration order on + * each transition; one listener throwing does NOT prevent the others + * from running (impl catches and logs per-callback). * * @param callback - Function called with the new token (or undefined on logout) */ @@ -56,12 +74,29 @@ export interface IAuthStateStore { * Fired when a token that was valid transitions to expired. * Only fires once per expiry (not on every poll cycle). * - * Only one callback supported — subsequent calls overwrite previous. + * Multiple callbacks supported. Listeners fire in registration order; + * one throwing does NOT prevent the others from running. * * @param callback - Function called with the expired token */ onAuthExpired(callback: AuthExpiredCallback): void + /** + * Register a pre-transition callback that fires BEFORE `cachedToken` + * mutates. The store awaits the callback (bounded by + * `beforeAuthChangeTimeoutMs`) before committing the new token and + * firing `onAuthChanged`. This is the M4.4 hook used by the analytics + * client to flush events under the OLD session header. + * + * Multiple callbacks are awaited in registration order, in series. A + * rejecting callback is logged best-effort and does NOT block + * subsequent callbacks or the transition itself. + * + * @param callback - Function called with `(oldToken, newToken)`; may + * return a Promise (will be awaited) or void. + */ + onBeforeAuthChange(callback: BeforeAuthChangedCallback): void + /** * Start polling the token store for changes. * Must be called after construction to begin monitoring. diff --git a/src/server/core/interfaces/storage/i-global-config-rotator.ts b/src/server/core/interfaces/storage/i-global-config-rotator.ts new file mode 100644 index 000000000..2064183d0 --- /dev/null +++ b/src/server/core/interfaces/storage/i-global-config-rotator.ts @@ -0,0 +1,19 @@ +/** + * Rotates the device identity in the global config. Used by the auth RPC + * handlers when an explicit user-initiated identity transition occurs + * (logout, account-switch on login, refresh-failure sign-out) so the + * machine-level analytics identity does not survive the transition. + * + * Narrow interface so AuthHandler does not need a dependency on the full + * GlobalConfigHandler. + */ +export interface IGlobalConfigRotator { + /** + * Rewrites the on-disk `deviceId` with a fresh UUID, preserving the + * analytics flag and config version. No-ops when the config file does + * not yet exist (analytics never enabled — nothing to retire). + * + * @returns `true` if a rotation was performed, `false` if it was a no-op. + */ + rotateDeviceId(): Promise +} diff --git a/src/server/infra/analytics/analytics-backoff-policy.ts b/src/server/infra/analytics/analytics-backoff-policy.ts new file mode 100644 index 000000000..425374ac0 --- /dev/null +++ b/src/server/infra/analytics/analytics-backoff-policy.ts @@ -0,0 +1,78 @@ +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' + +// Schedule fixed by the M4.5 ticket: 30s, 60s, 2m, 5m, capped at 5m. +// Index = consecutiveFailures clamped to [0, length - 1]. +const BACKOFF_STEPS_MS: readonly number[] = [30_000, 60_000, 120_000, 300_000] + +// M5.4 (ENG-2658): upper bound on an honored server Retry-After. A hint above +// this is clamped down. Rationale: (1) well under Node's setTimeout ceiling +// (2^31-1 ms ≈ 24.8 days), past which a delay silently overflows and fires +// immediately — which would IGNORE the rate-limit; (2) a throttle window longer +// than an hour is unreasonable for opt-in telemetry and would otherwise stall +// shipping for days. The lower bound is handled separately by `nextDelayMs`'s +// max() with the schedule. +const MAX_SERVER_HINT_MS = 3_600_000 // 1 hour + +/** + * In-memory exponential-backoff policy. See `IAnalyticsBackoffPolicy` + * for the contract. + * + * Single private counter `failures` is the load-bearing state. The + * schedule lookup is a clamped index into `BACKOFF_STEPS_MS`, so the + * cap behavior falls out of the data shape rather than a separate + * conditional. + * + * Not thread-safe. The daemon runs in a single Node event loop; the + * scheduler's serialized tick chain is the only writer. + */ +export class AnalyticsBackoffPolicy implements IAnalyticsBackoffPolicy { + private failures = 0 + // M5.4: true while the last outcome was a server-driven rate-limit. Distinct + // from `failures` because a throttled endpoint is reachable, not failing. + private rateLimited = false + // M5.4 (ENG-2658): one-shot server-supplied delay from a 429 `Retry-After` + // or a 503 edge backstop. `undefined` = no active hint. Cleared on the next + // success or transient failure so it never outlives the rate-limit window. + private serverHintMs: number | undefined = undefined + + public applyServerHint(retryAfterMs: number): void { + // Ignore a non-positive / NaN hint for the delay floor (a bad server value + // must not shorten the wait), and clamp an absurdly large one to the safe + // maximum (a bad value must not overflow setTimeout or stall shipping for + // days). Either way, record that we were rate-limited. + if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + this.serverHintMs = Math.min(retryAfterMs, MAX_SERVER_HINT_MS) + } + + this.rateLimited = true + } + + public consecutiveFailures(): number { + return this.failures + } + + public isRateLimited(): boolean { + return this.rateLimited + } + + public nextDelayMs(): number { + const index = Math.min(this.failures, BACKOFF_STEPS_MS.length - 1) + // Take the larger of the scheduled delay and any server hint so a server + // can stretch the wait but never accelerate it below the safe minimum. + return Math.max(BACKOFF_STEPS_MS[index], this.serverHintMs ?? 0) + } + + public onFailure(): void { + this.failures += 1 + // A genuine transient failure supersedes any prior rate-limit hint: resume + // the pure exponential schedule rather than honoring a stale 429 delay. + this.serverHintMs = undefined + this.rateLimited = false + } + + public onSuccess(): void { + this.failures = 0 + this.serverHintMs = undefined + this.rateLimited = false + } +} diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts new file mode 100644 index 000000000..387c7a01d --- /dev/null +++ b/src/server/infra/analytics/analytics-client.ts @@ -0,0 +1,448 @@ +import {formatISO} from 'date-fns' +import {randomUUID} from 'node:crypto' + +import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' +import type {PropsArg, PropsForEvent} from '../../../shared/analytics/events/index.js' +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' +import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' +import type {IIdentityResolver} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {ISuperPropertiesResolver} from '../../core/interfaces/analytics/i-super-properties-resolver.js' + +import {toWireEvent} from '../../../shared/analytics/stored-record.js' +import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' + +export interface AnalyticsClientDeps { + /** + * M4.5: optional failure-resilience policy. When wired, `runFlush` + * feeds the `SendResult.reason` into the policy after every flush: + * - undefined reason (all-succeeded) → `onSuccess()` resets the backoff. + * - `timeout` / `network` / `http_5xx` → `onFailure()` advances the + * backoff one step (capped at 5m by the policy impl). + * - `http_4xx` → neither call. 4xx is a payload-shape error, not a + * backend health signal — retrying or backing off won't help. + * - Aborted (controller.signal.aborted) → neither call. User-driven + * cancellation (M4.4 disable) must not poison the M4.6 + * reachability counter. + * + * Optional so M2/M4.3 test fakes that don't care about backoff keep + * working with their pre-M4.5 construction shape. + */ + backoffPolicy?: IAnalyticsBackoffPolicy + identityResolver: IIdentityResolver + isEnabled: () => boolean + jsonlStore: IJsonlAnalyticsStore + /** + * Optional structured log sink for operational visibility. Used by + * `onAuthTransition` to surface a `clear()` failure that would + * otherwise silently leave prior-session events on disk. Defaults to + * a no-op when omitted so existing callers don't have to wire it. + */ + log?: (message: string) => void + /** + * M4.6: monotonic clock used to stamp `lastSuccessfulFlushAt`. Injected + * so tests can assert against a known value; production defaults to + * `Date.now`. Daemon restart resets the in-memory timestamp; the + * status command surfaces "never" when undefined. + */ + now?: () => number + /** + * M4.3: optional notification fired after a record has been durably + * appended (JSONL + queue mirror). The composition root wires this to + * `AnalyticsFlushScheduler.notifyPushed()` so the scheduler can check + * its 20-event threshold without coupling AnalyticsClient to the + * scheduler's concrete type. Called best-effort — throws are swallowed + * by the surrounding try/catch in `trackAsync`. + */ + onAfterTrack?: () => void + queue: IAnalyticsQueue + sender: IAnalyticsSender + superPropsResolver: ISuperPropertiesResolver +} + +/** + * Daemon-scoped analytics client. Implements the M2.1 IAnalyticsClient + * contract by composing M2.2 (queue), M2.3 (super-props), and M2.4 + * (identity). + * + * `track()` is sync per the M2.1 interface — when enabled, the actual + * resolve+enqueue work is fire-and-forget via the async trackAsync, + * matching the established `auth-state-store.ts` pattern. Errors during + * the async work (resolver rejection, queue push failure) are silently + * swallowed: analytics MUST NOT crash a correctly-configured consumer, + * and per ticket scope no error reporting surface exists yet. + * + * The no-crash guarantee covers ASYNC errors only. The sync `isEnabled()` + * callback is called directly; if it throws, the throw propagates to the + * caller. This is intentional: `isEnabled` is wired to + * GlobalConfigHandler.getCachedAnalytics(), which throws when invoked + * before `refreshCache()` has populated the cache. That throw surfaces + * a bootstrap-misconfiguration bug loudly rather than silently miscounting. + * Callers MUST ensure the cache is populated before the first `track()`. + * + * When disabled, `track()` is a true no-op: no resolver calls, no + * allocations beyond the function call frame. + */ +export class AnalyticsClient implements IAnalyticsClient { + // M4.4 cancellation slot. Held only while a flush is in flight; the + // signal is piped through `sender.send` to the underlying HTTP client. + // `abort()` is a no-op when this is undefined (no in-flight to cancel). + private currentFlushController?: AbortController + private readonly deps: AnalyticsClientDeps + // M4.6: timestamp of the last flush that actually shipped at least one + // record cleanly (same gate as the M4.5 backoff `onSuccess()` path). + // Surfaced through `getRuntimeState()` for `brv settings get analytics.status`. + // Daemon restart resets to undefined; status renders "never". + private lastSuccessfulFlushAt: number | undefined + // Single-flight slot for an in-flight `flush()`. Concurrent callers join the + // existing promise instead of starting a second read-then-decide cycle — + // without this, two parallel flushes would both `loadPending()` the same set, + // both invoke `sender.send`, and both mirror `updateStatus(_, 'failed')` into + // the write chain (which serializes the WRITES but not the READ-decisions), + // double-incrementing `attempts` per cycle and tripping the M9.2 retry cap + // in MAX_ATTEMPTS/2 cycles instead of MAX_ATTEMPTS. + private pendingFlush?: Promise + // M4.1 in-flight tracking. Each `trackAsync` registers its promise here + // so `onAuthTransition` can await every track that started BEFORE the + // transition before issuing `clear()`. Without this barrier: + // - a track that resolved old identity but hasn't appended yet may + // enqueue its append AFTER clear → record persists with stale + // identity → backend rejects on mismatch. + // - a track that already enqueued append BEFORE clear is correctly + // nuked by clear (intentional — pre-transition events drop). + // The barrier removes the first failure mode; the second is the + // designed behavior. + private readonly pendingTracks = new Set>() + + public constructor(deps: AnalyticsClientDeps) { + this.deps = deps + } + + /** + * M4.4 cancellation hook. Aborts the AbortController tied to the + * in-flight `flush()`'s HTTP request (if any). The signal propagates + * through `sender.send` to the underlying `IAnalyticsHttpClient`, + * which classifies aborted requests as `network` failures — JSONL + * records stay `pending` (so they ship on the next enabled flush). + * + * Called from `GlobalConfigHandler` when `brv settings set analytics.share false` + * flips the flag, so the daemon doesn't half-ship a batch across an + * enable/disable boundary. No-op when no flush is in flight. + */ + public abort(): void { + this.currentFlushController?.abort() + } + + /** + * Reads pending rows from JSONL (NOT from the in-memory queue), invokes + * the registered sender, and mirrors the per-record outcome back to JSONL + * via `updateStatus`. The queue is intentionally bypassed: it can drop + * oldest entries on burst overflow (>maxSize), and a queue-based flush + * would miss those rows even though JSONL still has them. + * + * Returns an `AnalyticsBatch` of wire-shape events (id/attempts/status + * stripped via `toWireEvent`) so a future caller can inspect what was + * shipped on this tick. `flush()` itself does NOT transmit — the sender + * does. The returned batch reflects the input snapshot, not the per-record + * succeeded/failed split. + * + * A sender that throws is treated as `{succeeded: [], failed: }` + * — analytics MUST NOT crash the daemon. M9.2's `updateStatus(_, 'failed')` + * owns the retry-cap policy: rows stay at `'pending'` until + * `attempts >= MAX_ATTEMPTS`, then transition to terminal `'failed'`. + * `flush()` is a thin caller — it does not inspect attempts. + */ + public async flush(): Promise { + // M4.4: `brv settings set analytics.share false` semantically means "stop shipping to + // remote" — local tracking (JSONL + queue) continues unconditionally. + // Gate here, NOT in `track()`. Records stay at `status='pending'` in + // JSONL; the next flush after re-enable picks them up automatically. + if (!this.deps.isEnabled()) return AnalyticsBatch.create([]) + + // Single-flight: if a flush is already running, hand its promise to the + // joining caller so both observe the same loadPending snapshot, the same + // sender invocation, and the same mirror writes. + if (this.pendingFlush !== undefined) return this.pendingFlush + + this.pendingFlush = this.runFlush() + try { + return await this.pendingFlush + } finally { + this.pendingFlush = undefined + } + } + + /** + * Snapshot of client-owned runtime state for `brv settings get analytics.status` + * (M4.6). Backoff state, endpoint, and the enabled flag are NOT here + * — those are composed by the daemon-side status handler from other + * sources (the policy + envConfig + GlobalConfigHandler). Async + * because `queueDepth` reads JSONL pending rows (the authoritative + * "waiting to ship" metric, NOT the in-memory queue mirror which + * caps at 1000 via drop-oldest). + */ + public async getRuntimeState(): Promise<{droppedCount: number; lastSuccessfulFlushAt: number | undefined; queueDepth: number}> { + const pending = await this.deps.jsonlStore.loadPending() + return { + droppedCount: this.deps.queue.droppedCount(), + lastSuccessfulFlushAt: this.lastSuccessfulFlushAt, + queueDepth: pending.length, + } + } + + public async onAuthTransition(): Promise { + // Snapshot in-flight tracks then wait for them to settle. Any + // `trackAsync` that started before this point may still be between + // identity-resolve and `jsonlStore.append` / `queue.push`; awaiting + // it guarantees its append has either landed in the write chain (so + // the clear enqueued below nukes it — correct, those identities are + // stale) or failed (so there is nothing to nuke). New `track()` + // calls that arrive after this snapshot resolve identity from the + // post-transition cached token and are NOT included in the barrier. + // + // `Promise.allSettled` rather than `all` because individual track + // promises may already swallow-and-resolve on error; we just need + // the settled signal, not the result. + if (this.pendingTracks.size > 0) { + await Promise.allSettled(this.pendingTracks) + } + + // Drain the in-memory mirror AFTER the barrier so any push that the + // completing track did is also wiped. Draining before the barrier + // would leave a window where the late-completing track pushes back + // into a fresh queue → prior-session record stays visible to webui. + this.deps.queue.drain() + + // NOTE: we intentionally do NOT await an in-flight `flush()` (the + // `pendingFlush` slot) before clearing. If a flush is mid-send when this + // runs, it already loaded its records and will later call + // `jsonlStore.updateStatus(...)` on ids the clear below removed — which is + // a safe no-op (the store ignores non-matching ids). That flush ships + // those pre-transition events under the OLD identity, which is exactly + // what the M4.4 pre-transition flush hook (`wireAnalyticsAuthPreTransition`) + // is for; clearing afterward drops whatever it didn't carry. Awaiting the + // flush here would only add latency to the auth transition for no + // correctness gain, so the barrier is on tracks + queue, not the flush. + try { + await this.deps.jsonlStore.clear() + } catch (error) { + // Analytics MUST NOT crash the consumer. Surface the failure + // through the optional log sink so operators see why a flush + // after transition would ship prior-session events. + this.deps.log?.( + `analytics.onAuthTransition: clear failed (${error instanceof Error ? error.message : String(error)})`, + ) + } + } + + public track(event: E, ...rest: PropsArg): void { + // M4.4 semantic: local tracking is unconditional. `isEnabled` only + // gates `flush()` (remote send). A disabled session still writes + // every track to JSONL + the in-memory queue; re-enabling picks the + // backlog up on the next flush. + // Capture the timestamp synchronously at call-site so it reflects WHEN the + // user action happened, not when the async resolver chain settled. Under + // burst load (many tracks queued before the first resolver completes) this + // preserves the inter-event durations downstream consumers care about. + // + // A single `new Date()` read drives both fields so the local numeric + // sort key (`timestamp`, epoch ms) and the wire-bound ISO 8601 string + // (`created_at`) always describe the same instant. + const now = new Date() + const timestamp = now.getTime() + const createdAt = formatISO(now) + const [properties] = rest + const pending = this.trackAsync({createdAt, event, properties, timestamp}) + this.pendingTracks.add(pending) + // Remove from the in-flight set once the track settles either way. + // `void` keeps `track()` synchronous per the IAnalyticsClient contract. + // eslint-disable-next-line no-void + void pending.finally(() => { + this.pendingTracks.delete(pending) + }) + } + + /** + * Feed the `SendResult` into the optional M4.5 backoff policy. + * + * Decision table (skip = call neither onSuccess nor onFailure): + * - policy not wired → skip + * - aborted (M4.4 disable cancel) → skip (user action, not a backend signal) + * - reason = `http_4xx` → skip (payload-shape, not a + * health signal; e.g. HttpAnalyticsSender's `missing-deviceId` path, + * which classifies as `http_4xx` rather than shipping) + * - reason undefined AND succeeded.length === 0 → skip (empty no-op + * race, or an uncategorized failed-without-reason result; no health + * signal either way) + * - reason undefined AND succeeded.length > 0 → onSuccess() + M4.6 timestamp stamp + * - reason = `timeout` / `network` / `http_5xx` → onFailure() + * + * Emits a structured log line on every real transition so ops can + * trace "why did flushes suddenly slow down" without grepping for + * implicit cadence changes. + */ + private feedBackoffPolicy(result: SendResult, aborted: boolean): void { + const policy = this.deps.backoffPolicy + if (aborted) return + if (result.reason === 'http_4xx') { + if (policy !== undefined) { + // Tag 4xx in the log so ops sees the divergence (we do NOT advance + // backoff for permanent payload errors, only for transient ones). + this.deps.log?.( + `analytics.backoff: http_4xx ignored (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, + ) + } + + return + } + + if (result.reason === 'rate_limited') { + // M5.4 (ENG-2658): a 429 (app throttler) or 503 (nginx edge backstop) is + // a "slow down", not a backend failure. Honor the server's delay via + // `applyServerHint` (the scheduler re-arms from `nextDelayMs()` after this + // flush settles) and DO NOT advance the failure counter, so a throttled + // endpoint never tips the reachability band into "unreachable". + if (policy === undefined) return + if (result.retryAfterMs === undefined) { + // The sender contract pairs `retryAfterMs` with every `rate_limited` + // result. If a future producer breaks that, surface it in the log AND + // still flip the policy's rate-limited bit — via a non-finite sentinel, + // so no delay floor is set but `isRateLimited()` turns true. That keeps + // the scheduler's burst gate closed so the next 20-event burst doesn't + // hammer a server we were just told to back off from; the M4.5 schedule + // still drives the next-tick delay. + this.deps.log?.( + 'analytics.backoff: rate_limited result missing retryAfterMs hint — falling back to the schedule, burst suppressed', + ) + policy.applyServerHint(Number.NaN) + return + } + + policy.applyServerHint(result.retryAfterMs) + this.deps.log?.( + `analytics.backoff: rate_limited, honoring server hint retry_after=${result.retryAfterMs}ms ` + + `(next=${policy.nextDelayMs()}ms, consecutive_failures=${policy.consecutiveFailures()})`, + ) + return + } + + if (result.reason === undefined) { + if (result.succeeded.length === 0) return // empty no-op or uncategorized failure: no signal + // M4.6: stamp the timestamp on the same gate as the backoff + // `onSuccess()` so "Last successful flush" reflects real ships. + const now = (this.deps.now ?? Date.now)() + this.lastSuccessfulFlushAt = now + if (policy !== undefined) { + const beforeFailures = policy.consecutiveFailures() + policy.onSuccess() + if (beforeFailures > 0) { + this.deps.log?.( + `analytics.backoff: reset on success (was consecutive_failures=${beforeFailures}, next=${policy.nextDelayMs()}ms)`, + ) + } + } + + return + } + + if (policy !== undefined) { + policy.onFailure() + this.deps.log?.( + `analytics.backoff: advanced on ${result.reason} (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, + ) + } + } + + private async runFlush(): Promise { + const records = await this.deps.jsonlStore.loadPending() + + // M4.4: per-flush AbortController, exposed via `abort()` so the + // disable-handler can cancel the in-flight HTTP. Cleared in finally + // so a stale controller can't be aborted after settlement. + const controller = new AbortController() + this.currentFlushController = controller + + let result: SendResult + try { + result = await this.deps.sender.send(records, {signal: controller.signal}) + } catch { + result = {failed: records.map((r) => r.id), succeeded: []} + } finally { + if (this.currentFlushController === controller) { + this.currentFlushController = undefined + } + } + + await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') + // M4.4 N3 fix: when we cancelled the send ourselves (`abort()` fired + // because `brv settings set analytics.share false` flipped the flag), DO NOT mark the + // failed records as 'failed' — that bumps the M9.2 retry-cap + // `attempts` counter on every cancel, and a few disable/enable + // toggles during shipping could terminate records as `'failed'` + // before they ever land. Leaving them at `status='pending'` + // preserves the invariant the `abort()` JSDoc claims: aborted + // records ship cleanly on the next enabled flush. + if (!controller.signal.aborted) { + await this.deps.jsonlStore.updateStatus(result.failed, 'failed') + } + + this.feedBackoffPolicy(result, controller.signal.aborted) + + return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) + } + + private async trackAsync( + input: Readonly<{ + createdAt: string + event: E + properties: PropsForEvent | undefined + timestamp: number + }>, + ): Promise { + const {createdAt, event, properties, timestamp} = input + try { + const [identity, superProps] = await Promise.all([ + this.deps.identityResolver.resolve(), + this.deps.superPropsResolver.resolve(), + ]) + + // M9.3: compose a StoredAnalyticsRecord — JSONL is the durable source of + // truth (M10.2's flush reads from JSONL, not the queue). The queue is a + // fast in-memory mirror for status display / future webui hot path. + const record: StoredAnalyticsRecord = { + attempts: 0, + // eslint-disable-next-line camelcase + created_at: createdAt, + id: randomUUID(), + identity, + name: event, + // Super-properties are authoritative: they overwrite any user-supplied + // property with the same key. This guarantees a consistent envelope + // (cli_version, device_id, environment, node_version, os) on every event. + properties: {...properties, ...superProps}, + status: 'pending', + timestamp, + } + + // Persist to JSONL FIRST. If `append` throws — disk error, or + // `JsonlCapFullError` when the file-size cap is saturated with non-sent + // rows — the outer catch silently drops and queue.push is skipped. This + // preserves the "JSONL is source of truth" invariant: no record reaches + // the in-memory mirror queue without a durable on-disk row. + await this.deps.jsonlStore.append(record) + this.deps.queue.push(record) + + // M4.3: notify the flush scheduler that a record landed so it can + // check its 20-event threshold. Fires only on the durable success + // path (jsonlStore.append resolved + queue.push completed) so a + // failed persist does NOT trigger a flush of a queue that did not + // grow. Errors are swallowed by the outer try/catch. + this.deps.onAfterTrack?.() + } catch { + // Analytics MUST NOT crash the consumer. Errors silently dropped. + } + } +} diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts new file mode 100644 index 000000000..24b4ce995 --- /dev/null +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -0,0 +1,320 @@ +const DEFAULT_INTERVAL_MS = 30_000 +const DEFAULT_THRESHOLD_COUNT = 20 + +export interface AnalyticsFlushSchedulerDeps { + /** + * Async flush operation invoked when a trigger fires. MUST NOT throw — + * the scheduler wraps every call in `.catch` so a flush failure cannot + * crash the interval loop or shutdown sequence. + */ + flush: () => Promise + /** + * Lazy analytics-enabled gate. Re-checked on every trigger so a runtime + * `brv settings set analytics.share false` (M1.4) immediately suspends scheduled flushes + * without restarting the daemon. + */ + isEnabled: () => boolean + /** + * M5.4 (ENG-2658): live "is the backend currently rate-limiting us?" gate, + * wired to `AnalyticsBackoffPolicy.isRateLimited()`. When true, the + * threshold (burst) trigger is suppressed so a 20-event burst cannot hammer a + * backend that returned 429/503 and asked us to wait — the periodic tick, + * already stretched to the server's `Retry-After` via `nextIntervalMs`, ships + * the backlog once the window elapses. Defaults to never-rate-limited so the + * periodic-tick path and existing callers/tests are unaffected. + */ + isRateLimited?: () => boolean + /** + * M4.5: live next-tick delay in milliseconds. Read AFTER each tick + * settles, when the scheduler arms its `setTimeout` for the next + * tick — so the latest backoff state (advanced by the just-finished + * flush via `AnalyticsClient.runFlush`) takes effect immediately. + * + * Production wires this to `analyticsBackoffPolicy.nextDelayMs()`. + * Tests pass a literal (`() => 30_000`) or a closure over a mutable + * value to exercise dynamic intervals. Defaults to 30s so existing + * test fakes that omit the dep keep working. + */ + nextIntervalMs?: () => number + /** + * Count of records pending shipment (JSONL `status='pending'` rows). + * Used by the interval timer and `flushFinal()` to skip flushes when + * there is nothing left to ship. + * + * MUST track JSONL state, NOT the in-memory queue mirror: the queue + * never decrements after a successful flush (queue.drain only runs on + * auth transitions), so using it here would make the scheduler fire + * every 30s indefinitely and waste a no-op HTTP call each time. + * `HttpAnalyticsSender` flips rows from `pending` to `sent` on 2xx, so + * this counter shrinks as work completes. + * + * Async because reading the JSONL file is I/O; the cost is one read + * per trigger (≤ once per `intervalMs` plus any threshold firings). + */ + pendingCount: () => Promise + /** + * Synchronous in-memory queue depth, read by the threshold trigger + * inside `notifyPushed()`. Sync + cheap so `track()` stays on the + * fast-path; correctness here only requires that the counter grows + * monotonically across recent pushes, which the bounded queue + * satisfies. + */ + queueSize: () => number + /** Queue depth that trips the threshold-based trigger. Defaults to 20. */ + thresholdCount?: number +} + +export type FlushFinalOptions = { + /** Hard cap on how long the shutdown flush is allowed to take. */ + timeoutMs: number +} + +/** + * Drives automatic flushes for the daemon-scoped analytics client. + * + * Two triggers (whichever fires first wins): + * - **Periodic tick** (`nextIntervalMs()`, default 30s): each tick + * re-arms via `setTimeout` AFTER the previous flush settles, reading + * the delay live at arm-time. In production this is wired to + * `AnalyticsBackoffPolicy.nextDelayMs()`, so a failing backend + * stretches the gap to 60s → 2m → 5m (M4.5); on first success the + * policy resets and the next tick is 30s again. + * - **Threshold notification** (`thresholdCount`, default 20): callers + * invoke `notifyPushed()` after enqueuing a record; if the queue + * has grown by `thresholdCount` since the last threshold fire, a + * flush is scheduled via `setImmediate` so `track()` stays + * synchronous from the consumer's view. The threshold path is NOT + * throttled by the M4.5 *backoff schedule* (failures) — single-flight + * rate-limits it, and gating the 20-event burst on transient failures + * would defeat its batching purpose. It IS suppressed, however, while + * an explicit server *rate-limit* is active (M5.4 / ENG-2658 + * `isRateLimited`): a 429/503 means "stop sending", so the burst path + * stands down and the stretched periodic tick ships the backlog. + * + * Single-flight: while a flush is in flight, any new trigger is dropped + * (NOT queued). The in-flight promise is exposed via `flushFinal()` so + * shutdown can join it rather than starting a second send. + * + * `flushFinal({timeoutMs})` is the shutdown hook: races the in-flight or + * fresh flush against a timeout and resolves either way, so the daemon + * exit sequence cannot hang on a slow telemetry backend. + * + * Lifecycle owned by the composition root: `start()` after construction, + * `stop()` during shutdown (before `flushFinal()` so no new ticks fire + * mid-shutdown). + * + * Errors from `flush()` are swallowed at this layer. The M4.5 backoff + * policy reacts to the structured failure reason via + * `AnalyticsClient.runFlush`; the scheduler itself only needs the live + * `nextDelayMs()` value at each re-arm and otherwise keeps ticking. + */ +export class AnalyticsFlushScheduler { + private readonly deps: Required + // M4.5: handle of the most-recently armed `setTimeout` for the + // periodic tick. Each tick re-arms itself from `nextIntervalMs()` + // after the flush settles, so the backoff policy's latest state + // takes effect on the very next tick. `start()` is idempotent via + // this slot (a second start while running is a no-op). + private intervalHandle: ReturnType | undefined + // Snapshot of `queueSize` at the last threshold fire. Together with + // `thresholdCount` this gates `notifyPushed` on the DELTA since last + // fire (queue depths 20/40/60/...) instead of the absolute size — the + // queue mirror is monotonic across a session (drained only on auth + // transitions), so without a moving baseline every push past the + // first threshold crossing would re-fire. + private lastTriggerQueueSize: number = 0 + // Single-flight slot. Any trigger that arrives while this is set is + // dropped; `flushFinal()` awaits it so shutdown joins rather than races. + private pendingFlush: Promise | undefined + // M4.5: set true on `stop()` so a settling flush's `.finally` does + // NOT re-arm the next tick. Without this, calling `stop()` while a + // tick was in flight would still queue one more tick after the + // current one settled. + private stopped = false + + public constructor(deps: AnalyticsFlushSchedulerDeps) { + this.deps = { + flush: deps.flush, + isEnabled: deps.isEnabled, + isRateLimited: deps.isRateLimited ?? (() => false), + nextIntervalMs: deps.nextIntervalMs ?? (() => DEFAULT_INTERVAL_MS), + pendingCount: deps.pendingCount, + queueSize: deps.queueSize, + thresholdCount: deps.thresholdCount ?? DEFAULT_THRESHOLD_COUNT, + } + } + + /** + * Best-effort final flush for the daemon shutdown sequence. Races the + * underlying flush against `timeoutMs` and resolves either way so the + * caller cannot hang on a slow backend. + * + * Joins an in-flight flush (returns its promise) rather than starting + * a second send. Skips the flush entirely when there is nothing in + * JSONL pending (avoids a wasted no-op HTTP call during shutdown). + */ + public async flushFinal(options: FlushFinalOptions): Promise { + if (!this.deps.isEnabled()) return + + // Snapshot the existing in-flight before checking pendingCount so a + // concurrent flush we should join is honored even if pendingCount + // reports zero at this exact moment (race-safe: an in-flight flush + // implies records WERE pending when it started). + if (this.pendingFlush !== undefined) { + await this.race(this.pendingFlush, options.timeoutMs) + return + } + + if ((await this.deps.pendingCount()) === 0) return + + // Double-check the slot AFTER the pendingCount I/O. During that + // await, a competing trigger (a queued setImmediate from + // `notifyPushed`, or an interval tick still mid-flight when `stop()` + // ran) may have called `startFlush` and claimed `pendingFlush`. + // Without this re-check the next line would call `startFlush` again, + // overwrite the slot with a second promise, and the backend would + // ingest the same records twice. Join the in-flight flush instead. + if (this.pendingFlush !== undefined) { + await this.race(this.pendingFlush, options.timeoutMs) + return + } + + await this.race(this.startFlush(), options.timeoutMs) + } + + /** + * Called by `AnalyticsClient.track()` after enqueuing a record. Fires a + * flush via `setImmediate` once the queue has grown by `thresholdCount` + * since the last trigger, so `track()` stays synchronous from the + * consumer's view. + * + * Threshold uses `queueSize` (not `pendingCount`) because: (a) it runs + * on every track and must stay sync + cheap, and (b) the gate's intent + * is "fire every N pushes". The mirror is monotonic across a session + * (drained only on auth transitions), so we compare against a moving + * baseline `lastTriggerQueueSize` rather than the absolute size — + * otherwise every push past the first threshold crossing would re-fire + * and the batching contract would collapse for slow-emit workloads. + * + * When the queue size drops below the previous baseline (auth-transition + * drain), the baseline resets to 0 so the next N pushes fire again. + */ + public notifyPushed(): void { + if (!this.deps.isEnabled()) return + // M5.4 (ENG-2658): while the backend is rate-limiting us (429/503), suppress + // the burst trigger so a 20-event burst cannot hammer a backend that asked + // us to wait. The periodic tick — already stretched to the server's + // Retry-After via `nextIntervalMs` — ships the backlog once the window + // elapses. The threshold baseline is intentionally left untouched here, so + // once the rate-limit clears the next push can still trigger a flush. + if (this.deps.isRateLimited()) return + const size = this.deps.queueSize() + if (size < this.lastTriggerQueueSize) this.lastTriggerQueueSize = 0 + if (size - this.lastTriggerQueueSize < this.deps.thresholdCount) return + this.lastTriggerQueueSize = size + setImmediate(() => { + // eslint-disable-next-line no-void + void this.tryFlush() + }) + } + + /** + * Start the recurring tick. Idempotent: a second call while already + * running is a no-op (the slot is occupied). M4.5: implemented as a + * `setTimeout` chain so each tick reads `nextIntervalMs()` at arm-time; + * the backoff policy's latest state takes effect on the very next tick. + */ + public start(): void { + if (this.intervalHandle !== undefined) return + this.stopped = false + this.armNextTick() + } + + /** + * Stop the recurring tick. Idempotent. Does NOT cancel an in-flight + * flush — call `flushFinal()` for that. The `stopped` flag prevents + * a settling flush's `.finally` from arming one extra tick after stop. + */ + public stop(): void { + this.stopped = true + if (this.intervalHandle === undefined) return + clearTimeout(this.intervalHandle) + this.intervalHandle = undefined + } + + /** + * Arm the next periodic tick at `nextIntervalMs()` from now. Called + * by `start()` initially and by each tick's `.finally` after the + * flush settles. `stopped` guard short-circuits when the daemon is + * winding down so we don't keep firing post-stop(). + */ + private armNextTick(): void { + if (this.stopped) { + this.intervalHandle = undefined + return + } + + this.intervalHandle = setTimeout(() => { + // eslint-disable-next-line no-void + void this.tryFlush().finally(() => this.armNextTick()) + }, this.deps.nextIntervalMs()) + } + + /** + * Race the given flush promise against a timeout. Used by `flushFinal` + * to enforce the shutdown budget without blocking on a slow backend. + */ + private async race(flushPromise: Promise, timeoutMs: number): Promise { + await Promise.race([ + flushPromise, + new Promise((resolve) => { + setTimeout(resolve, timeoutMs) + }), + ]) + } + + /** + * Invoke the flush and own the single-flight slot for its lifetime. + * Errors are swallowed at this layer — M4.5 owns retry/backoff. + */ + private startFlush(): Promise { + const promise: Promise = this.deps + .flush() + .then( + () => { + // Discard the flush return value; the scheduler only cares + // about settlement, not the AnalyticsBatch payload. + }, + () => { + // Analytics MUST NOT crash the daemon. M4.5 will surface + // failure reasons via a different channel. + }, + ) + .finally(() => { + if (this.pendingFlush === promise) { + this.pendingFlush = undefined + } + }) + this.pendingFlush = promise + return promise + } + + /** + * Common gate for interval and threshold triggers. Honors the + * isEnabled gate, the empty-pending skip (JSONL-backed, not queue), + * and single-flight; delegates to `startFlush` for the actual call. + * + * Async so the pendingCount I/O is awaited inside the gate rather + * than fanned out as a fire-and-forget side effect. Errors are + * swallowed by `startFlush`; this method itself never throws. + */ + private async tryFlush(): Promise { + if (!this.deps.isEnabled()) return + if (this.pendingFlush !== undefined) return + if ((await this.deps.pendingCount()) === 0) return + // pendingFlush may have been set by a competing trigger during the + // pendingCount I/O — re-check before claiming the slot. + if (this.pendingFlush !== undefined) return + await this.startFlush() + } +} diff --git a/src/server/infra/analytics/axios-analytics-http-client.ts b/src/server/infra/analytics/axios-analytics-http-client.ts new file mode 100644 index 000000000..8a75b96aa --- /dev/null +++ b/src/server/infra/analytics/axios-analytics-http-client.ts @@ -0,0 +1,188 @@ +import type {AxiosInstance, AxiosResponse} from 'axios' + +import axios, {AxiosError} from 'axios' + +import type {AnalyticsBatch} from '../../core/domain/analytics/batch.js' +import type { + AnalyticsHttpHeaders, + AnalyticsHttpSendOptions, + AnalyticsHttpSendResult, + IAnalyticsHttpClient, +} from '../../core/interfaces/analytics/i-analytics-http-client.js' + +import {processLog} from '../../utils/process-logger.js' + +const DEFAULT_TIMEOUT_MS = 5000 +const EVENTS_PATH = '/v1/events' +// M5.4 (ENG-2658): delay applied when a 429 carries no `Retry-After` hint, or +// when the nginx edge backstop trips with a bare 503 (which never carries one). +const DEFAULT_RETRY_AFTER_MS = 60_000 + +type AxiosAnalyticsHttpClientOptions = { + baseUrl: string + /** + * Sink for operational WARN lines (M5.4 default-backoff fallback). Defaults + * to the daemon `processLog`; tests inject a spy to assert the WARN fired. + */ + log?: (message: string) => void + /** Override request timeout (default 5000ms). Test-only escape hatch. */ + timeoutMs?: number +} + +/** + * Production analytics HTTP transport over axios. + * + * Contract (per `IAnalyticsHttpClient` + ENG-2643): + * - One POST per call; no retries — M4.5 owns retry/backoff. + * - 5 second timeout enforced via the axios instance config. + * - Anonymous-friendly: no `Authorization` header, no token plumbing. + * `x-byterover-device-id` is mandatory; `x-byterover-session-id` is + * an optional backwards-compat hint (per-event identity is the + * authoritative source after M4.1). + * - MUST NOT throw. Every failure path returns a tagged + * `AnalyticsHttpSendResult` so the caller can keep the daemon up. + * + * Reason classification: timeout / 4xx / 5xx / network. Anything else + * (e.g. axios serialization bug) falls into `network` so callers always + * see a tagged result. + */ +export class AxiosAnalyticsHttpClient implements IAnalyticsHttpClient { + private readonly axios: AxiosInstance + private readonly log: (message: string) => void + + public constructor(options: AxiosAnalyticsHttpClientOptions) { + this.log = options.log ?? processLog + this.axios = axios.create({ + baseURL: options.baseUrl.replace(/\/+$/, ''), + // `validateStatus` returning true delegates HTTP-status classification + // to `classifyResponse` below; axios won't throw on 4xx/5xx so we can + // map them to tagged failure reasons without catching. + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + validateStatus: () => true, + }) + } + + public async send( + batch: AnalyticsBatch, + headers: AnalyticsHttpHeaders, + options?: AnalyticsHttpSendOptions, + ): Promise { + try { + const response = await this.axios.post(EVENTS_PATH, batch.toJson(), { + headers: this.composeHeaders(headers), + // M4.4: surface the caller's AbortSignal so `brv analytics + // disable` / daemon shutdown can cancel an in-flight POST. + // Pre-aborted signals are honored by axios (it short-circuits + // before dispatch). Aborted requests classify as `network` + // (client-side termination, not a server-side condition). + ...(options?.signal === undefined ? {} : {signal: options.signal}), + }) + return classifyResponse(response, this.log) + } catch (error: unknown) { + return classifyError(error, this.log) + } + } + + private composeHeaders(headers: AnalyticsHttpHeaders): Record { + const composed: Record = { + 'content-type': 'application/json', + 'user-agent': headers.userAgent, + 'x-byterover-device-id': headers.deviceId, + } + if (headers.sessionId !== undefined && headers.sessionId !== '') { + composed['x-byterover-session-id'] = headers.sessionId + } + + return composed + } +} + +const classifyResponse = (response: AxiosResponse, log: (message: string) => void): AnalyticsHttpSendResult => { + const {status} = response + if (status >= 200 && status < 300) return {ok: true} + // M5.4 (ENG-2658): the app throttler (@nestjs/throttler) returns 429 with a + // server-supplied `Retry-After`. Honor it (header, then `retry_after_seconds` + // body, then default) rather than treating it as a payload-shape 4xx. + if (status === 429) return classifyRateLimited(response, status, log) + if (status >= 400 && status < 500) return {ok: false, reason: 'http_4xx', status} + // M5.4: a bare 503 is typically the nginx edge backstop tripping (usually no + // `Retry-After`). Route it through the same rate-limit path as 429 so a 503 + // that DOES carry a server hint (maintenance page, alternate ingress, CDN) is + // honored rather than forced to the default — otherwise default delay + WARN. + // NOT an unreachable backend (the endpoint is up, we're being shed). Other + // 5xx stay `http_5xx` (genuine transient errors that drive exponential backoff). + if (status === 503) return classifyRateLimited(response, status, log) + + if (status >= 500 && status < 600) return {ok: false, reason: 'http_5xx', status} + // 1xx / 3xx without redirect handling reach here. Treat as network-level + // anomaly so callers see a tagged result rather than silently succeeding. + return {ok: false, reason: 'network'} +} + +const classifyRateLimited = ( + response: AxiosResponse, + status: number, + log: (message: string) => void, +): AnalyticsHttpSendResult => { + const fromHeader = parseRetryAfterHeaderMs(response.headers) + if (fromHeader !== undefined) return {ok: false, reason: 'rate_limited', retryAfterMs: fromHeader, status} + const fromBody = parseRetryAfterBodyMs(response.data) + if (fromBody !== undefined) return {ok: false, reason: 'rate_limited', retryAfterMs: fromBody, status} + log( + `analytics.http: ${status} without a usable Retry-After header or retry_after_seconds body, ` + + `applying default ${DEFAULT_RETRY_AFTER_MS}ms backoff`, + ) + return {ok: false, reason: 'rate_limited', retryAfterMs: DEFAULT_RETRY_AFTER_MS, status} +} + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** Parse a `Retry-After` header (RFC 7231) — delay-seconds OR HTTP-date — to milliseconds. */ +const parseRetryAfterHeaderMs = (headers: unknown): number | undefined => { + if (!isObject(headers)) return undefined + // axios lowercases response header keys. + const raw = headers['retry-after'] + if (typeof raw !== 'string' && typeof raw !== 'number') return undefined + // Preferred form: delay-seconds. + const asSeconds = Number(raw) + if (Number.isFinite(asSeconds) && asSeconds > 0) return Math.round(asSeconds * 1000) + // Alternate form: an HTTP-date — convert to a forward-looking delay. A date in + // the past (or an unparseable value) yields no usable hint. An absurdly + // far-future date is bounded downstream by the policy's MAX_SERVER_HINT_MS + // cap, so there is no setTimeout-overflow risk here. + const targetMs = Date.parse(String(raw)) + if (!Number.isFinite(targetMs)) return undefined + const deltaMs = targetMs - Date.now() + return deltaMs > 0 ? deltaMs : undefined +} + +/** Parse a `retry_after_seconds` JSON body field (the throttler's fallback). */ +const parseRetryAfterBodyMs = (data: unknown): number | undefined => { + if (!isObject(data)) return undefined + const seconds = data.retry_after_seconds + return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 + ? Math.round(seconds * 1000) + : undefined +} + +const classifyError = (error: unknown, log: (message: string) => void): AnalyticsHttpSendResult => { + if (axios.isAxiosError(error)) { + // Timeout: axios surfaces this as `ECONNABORTED` with `code === 'ECONNABORTED'`, + // or `ETIMEDOUT` on socket-level timeouts. + if (isTimeoutCode(error)) return {ok: false, reason: 'timeout'} + // Response present but classifyResponse didn't run (shouldn't happen given + // `validateStatus: () => true`, but defensively re-classify here). + if (error.response !== undefined) return classifyResponse(error.response, log) + return {ok: false, reason: 'network'} + } + + // Non-axios throws (e.g. JSON.stringify bug from a circular-reference event) + // map to network so the caller always sees a tagged result. + return {ok: false, reason: 'network'} +} + +const isTimeoutCode = (error: AxiosError): boolean => { + const {code} = error + return code === 'ECONNABORTED' || code === 'ETIMEDOUT' +} diff --git a/src/server/infra/analytics/bounded-queue.ts b/src/server/infra/analytics/bounded-queue.ts new file mode 100644 index 000000000..4c325f345 --- /dev/null +++ b/src/server/infra/analytics/bounded-queue.ts @@ -0,0 +1,54 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' + +const DEFAULT_MAX_SIZE = 1000 + +/** + * In-memory bounded queue with drop-oldest semantics. Newest pushes + * always succeed; if the queue is at capacity, the oldest record is + * removed first. `droppedCount` is cumulative across the queue's + * lifetime — neither `drain` nor any other method resets it. + * + * Backing store is a plain Array; at the default `maxSize` of 1000 the + * O(n) cost of `Array.prototype.shift()` on overflow is negligible. + * + * Since M9.3 the queue carries `StoredAnalyticsRecord` (with `id` local + * metadata) as a fast in-memory mirror of the JSONL source-of-truth. + * Drop-oldest evictions here are recoverable because M10.2's `flush()` + * reads from JSONL, not from this queue. + */ +export class BoundedQueue implements IAnalyticsQueue { + private dropped = 0 + private readonly maxSize: number + private records: StoredAnalyticsRecord[] = [] + + public constructor(maxSize: number = DEFAULT_MAX_SIZE) { + if (!Number.isInteger(maxSize) || maxSize < 0) { + throw new Error(`BoundedQueue maxSize must be a non-negative integer; got ${maxSize}`) + } + + this.maxSize = maxSize + } + + public drain(): StoredAnalyticsRecord[] { + const drained = this.records + this.records = [] + return drained + } + + public droppedCount(): number { + return this.dropped + } + + public push(record: StoredAnalyticsRecord): void { + this.records.push(record) + while (this.records.length > this.maxSize) { + this.records.shift() + this.dropped++ + } + } + + public size(): number { + return this.records.length + } +} diff --git a/src/server/infra/analytics/build-status-snapshot.ts b/src/server/infra/analytics/build-status-snapshot.ts new file mode 100644 index 000000000..bf1da48a7 --- /dev/null +++ b/src/server/infra/analytics/build-status-snapshot.ts @@ -0,0 +1,85 @@ +import type {AnalyticsStatusResponse} from '../../../shared/transport/events/analytics-events.js' +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' + +/** + * User-facing reachability label derived from the M4.5 backoff policy's + * `consecutiveFailures()` counter. Boundaries fixed by the M4.6 ticket: + * - 0 failures -> healthy + * - 1 or 2 failures -> degraded + * - 3+ failures -> unreachable + * + * The mapper is pure (presentation layer) so the policy stays free of + * UX concerns and so non-status consumers of `consecutiveFailures()` + * can apply different labels if needed. + * + * Defensive on invalid input: negative or NaN inputs return 'healthy' + * (the most optimistic label) rather than throw, so a malformed counter + * never breaks the status command's hot path. + */ +export type ReachabilityState = 'degraded' | 'healthy' | 'rate_limited' | 'unreachable' + +export function consecutiveFailuresToReachabilityState(consecutiveFailures: number): ReachabilityState { + if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < 1) return 'healthy' + if (consecutiveFailures < 3) return 'degraded' + return 'unreachable' +} + +const NOT_CONFIGURED_ENDPOINT = '(not configured)' + +export interface BuildAnalyticsStatusSnapshotDeps { + readonly analyticsClient: IAnalyticsClient + readonly backoffPolicy: IAnalyticsBackoffPolicy + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. Empty string when the env var + * isn't set; the builder substitutes the `(not configured)` placeholder + * AND forces `backoff.state = 'unreachable'` to reflect that no real + * health signal is possible. + */ + readonly endpoint: string + readonly isAnalyticsEnabled: () => boolean +} + +/** + * Composes the analytics-status wire response from runtime state, backoff + * state, endpoint, and the enabled flag. + * + * Shared between the legacy `analytics:status` transport event (M4.6 + * `AnalyticsStatusHandler`) and the new `settings:get` / `settings:list` + * routing for the `analytics.status` readonly-info descriptor (M16.3). + * + * Pure async function — no transport, no side effects. Throwing is fatal + * to the caller; the M16.1 `SettingsHandler` LIST path isolates per-row + * provider errors via `Promise.allSettled`-style catching, so a transient + * failure here surfaces as `current: undefined` on the row rather than + * blanking the whole settings response. + */ +export async function buildAnalyticsStatusSnapshot( + deps: BuildAnalyticsStatusSnapshotDeps, +): Promise { + const runtime = await deps.analyticsClient.getRuntimeState() + const consecutiveFailures = deps.backoffPolicy.consecutiveFailures() + const nextDelayMs = deps.backoffPolicy.nextDelayMs() + const endpointConfigured = deps.endpoint !== '' + const endpoint = endpointConfigured ? deps.endpoint : NOT_CONFIGURED_ENDPOINT + // M4.6 override: when no endpoint is configured the daemon has + // nothing to be "healthy" against — surface unreachable so the user + // doesn't see a misleading "healthy" label paired with "(not configured)". + // M5.4 (ENG-2658): a server-driven rate-limit (429 / 503 edge backstop) is + // a distinct state — the backend is reachable but throttling us — and takes + // precedence over the failure-count band (rate-limits never bump that count). + const state: ReachabilityState = endpointConfigured + ? deps.backoffPolicy.isRateLimited() + ? 'rate_limited' + : consecutiveFailuresToReachabilityState(consecutiveFailures) + : 'unreachable' + + return { + backoff: {consecutiveFailures, nextDelayMs, state}, + droppedCount: runtime.droppedCount, + enabled: deps.isAnalyticsEnabled(), + endpoint, + ...(runtime.lastSuccessfulFlushAt === undefined ? {} : {lastFlushAt: runtime.lastSuccessfulFlushAt}), + queueDepth: runtime.queueDepth, + } +} diff --git a/src/server/infra/analytics/draining-analytics-sender.ts b/src/server/infra/analytics/draining-analytics-sender.ts new file mode 100644 index 000000000..3fc2b37d0 --- /dev/null +++ b/src/server/infra/analytics/draining-analytics-sender.ts @@ -0,0 +1,37 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type { + AnalyticsSenderOptions, + IAnalyticsSender, + SendResult, +} from '../../core/interfaces/analytics/i-analytics-sender.js' + +/** + * Draining sender: reports every input record as `succeeded` without any + * network I/O, so the flush wiring transitions the matching JSONL rows to + * `status='sent'` and the pending count stays at 0. + * + * `wireAnalyticsHttpSender` swaps this in when `BRV_ANALYTICS_BASE_URL` + * resolves to `undefined` (absent, empty after trim, or malformed). No HTTP + * client is constructed; the axios layer is never touched, so a misconfigured + * build never burns retries or leaks events into the upstream backend. This + * optimizes for the "never ship" case (open-source forks, CI, air-gapped + * installs). + * + * Contrast with the test-seam `NoOpAnalyticsSender` (no-op-analytics-sender.ts), + * which returns BOTH arrays empty so the JSONL rows stay `pending` — used by + * tests to assert the "leave-JSONL-untouched" invariant; never wired in + * production. The behavioural name `Draining` is deliberately distinct from + * the test seam's `NoOp` so the production wiring can never grab the wrong + * sender (the prior `Noop`/`NoOp` pair differed by a single letter). + */ +export class DrainingAnalyticsSender implements IAnalyticsSender { + public async send( + records: readonly StoredAnalyticsRecord[], + _options?: AnalyticsSenderOptions, + ): Promise { + // `_options.signal` intentionally ignored: there is no transport to + // cancel. Accepting the parameter keeps structural assignability to + // `IAnalyticsSender` clean. + return {failed: [], succeeded: records.map((record) => record.id)} + } +} diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts new file mode 100644 index 000000000..279f526d1 --- /dev/null +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -0,0 +1,114 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type {IAnalyticsHttpClient} from '../../core/interfaces/analytics/i-analytics-http-client.js' +import type { + AnalyticsSenderOptions, + IAnalyticsSender, + SendResult, +} from '../../core/interfaces/analytics/i-analytics-sender.js' +import type {IAuthStateReader} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +import {toWireEvent} from '../../../shared/analytics/stored-record.js' +import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' + +export interface HttpAnalyticsSenderDeps { + authStateReader: IAuthStateReader + globalConfigStore: IGlobalConfigStore + httpClient: IAnalyticsHttpClient + userAgent: string +} + +/** + * Bridges the M10.1 `IAnalyticsSender` contract over an + * `IAnalyticsHttpClient`. The sender owns wire-format composition + * (records → `AnalyticsBatch`) and request-level header assembly + * (device id, session id, user-agent); the http client owns transport + * (timeout, status classification, network errors). + * + * Mapping rules: + * - Empty input → `{succeeded: [], failed: []}` without an HTTP call. + * - HTTP success → every input id classified as `succeeded`. + * - HTTP failure (timeout / 4xx / 5xx / network) → every input id + * classified as `failed`; M9.2's retry-cap inside `JsonlAnalyticsStore. + * updateStatus(_, 'failed')` increments `attempts` and terminates rows + * at MAX_ATTEMPTS. Backoff (M4.5) reacts to the structured failure + * reason later. + * + * Per-record granularity is intentionally collapsed here: the backend's + * 200 response is batch-level (it counts accepted/rejected internally + * via `IngestBatchResult` but does not surface per-event ids). All-or- + * nothing matches that contract. + * + * MUST NOT throw — analytics MUST NOT crash the daemon. Collaborator + * failures (e.g. globalConfigStore disk error) are caught and surface + * as `failed` so the retry policy can react. + */ +export class HttpAnalyticsSender implements IAnalyticsSender { + private readonly deps: HttpAnalyticsSenderDeps + + public constructor(deps: HttpAnalyticsSenderDeps) { + this.deps = deps + } + + public async send( + records: readonly StoredAnalyticsRecord[], + options?: AnalyticsSenderOptions, + ): Promise { + if (records.length === 0) return {failed: [], succeeded: []} + + const ids = records.map((r) => r.id) + try { + const config = await this.deps.globalConfigStore.read() + const deviceId = config?.deviceId + if (deviceId === undefined || deviceId === '') { + // Backend requires `x-byterover-device-id` on every batch. Without + // it the request would be 400-rejected, so classify the failure as + // `http_4xx` (a payload-shape problem, not a transient backend + // signal). The M4.5 backoff policy then suppresses advancement + // rather than churning on this daemon-side misconfig, while the + // retry-cap still bumps attempts and eventually terminates the rows + // — same terminal classification any other failure reason gets. + return {failed: [...ids], reason: 'http_4xx', succeeded: []} + } + + const sessionKey = this.deps.authStateReader.getToken()?.sessionKey + const batch = AnalyticsBatch.create(records.map((r) => toWireEvent(r))) + const httpResult = await this.deps.httpClient.send( + batch, + { + deviceId, + ...(sessionKey !== undefined && sessionKey !== '' ? {sessionId: sessionKey} : {}), + userAgent: this.deps.userAgent, + }, + // M4.4: forward the cancellation signal so `brv settings set analytics.share false` + // (or shutdown) can abort an in-flight POST. The http client + // classifies aborted requests as `network`, which maps here to + // an all-failed result — same as any other transport failure. + options?.signal === undefined ? undefined : {signal: options.signal}, + ) + + if (httpResult.ok) return {failed: [], succeeded: [...ids]} + // M5.4 (ENG-2658): `rate_limited` (429 / 503 edge backstop) carries the + // server's retry delay; forward it so `AnalyticsClient` can honor it via + // `backoffPolicy.applyServerHint` instead of advancing the failure count. + if (httpResult.reason === 'rate_limited') { + return {failed: [...ids], reason: 'rate_limited', retryAfterMs: httpResult.retryAfterMs, succeeded: []} + } + + // M4.5: surface the http-level failure reason so AnalyticsClient + // can feed it into the backoff policy. `http_4xx` is intentionally + // forwarded as-is so the caller can suppress backoff advancement + // (4xx is a payload-shape problem, not a transient signal). + return {failed: [...ids], reason: httpResult.reason, succeeded: []} + } catch { + // Defensive: any collaborator surprise (config read throws, + // toWireEvent edge case, etc.) maps to a batch-level failure. + // The retry-cap policy owns terminal classification. Tagged as + // `network` for M4.5 — internal collaborator failures are treated + // as transient (try again later), not as permanent payload-shape + // errors. M4.5's `AnalyticsClient` advances the backoff policy + // when it sees this reason. + return {failed: [...ids], reason: 'network', succeeded: []} + } + } +} diff --git a/src/server/infra/analytics/identity-resolver.ts b/src/server/infra/analytics/identity-resolver.ts new file mode 100644 index 000000000..dca4db3ef --- /dev/null +++ b/src/server/infra/analytics/identity-resolver.ts @@ -0,0 +1,42 @@ +/* eslint-disable camelcase */ +import type {Identity} from '../../core/domain/analytics/identity.js' +import type {IAuthStateReader, IIdentityResolver} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +/** + * Builds the per-event Identity from current auth state + GlobalConfig. + * + * - Anonymous (no auth token) → `{device_id}` only. + * - Registered → `{user_id, email?, name?, device_id}` where empty + * `email` / `name` cause the property to be OMITTED (not present + * as `undefined`) so downstream serializers don't emit `"email": null`. + * + * No caching — each `resolve()` call re-reads both sources so auth-state + * transitions take effect immediately. + */ +export class IdentityResolver implements IIdentityResolver { + private readonly authStateReader: IAuthStateReader + private readonly globalConfigStore: IGlobalConfigStore + + public constructor(authStateReader: IAuthStateReader, globalConfigStore: IGlobalConfigStore) { + this.authStateReader = authStateReader + this.globalConfigStore = globalConfigStore + } + + public async resolve(): Promise { + const config = await this.globalConfigStore.read() + const device_id = config?.deviceId ?? '' + const token = this.authStateReader.getToken() + + if (!token) { + return {device_id} + } + + return { + device_id, + user_id: token.userId, + ...(token.userEmail ? {email: token.userEmail} : {}), + ...(token.userName ? {name: token.userName} : {}), + } + } +} diff --git a/src/server/infra/analytics/jsonl-analytics-store.ts b/src/server/infra/analytics/jsonl-analytics-store.ts new file mode 100644 index 000000000..0093ec245 --- /dev/null +++ b/src/server/infra/analytics/jsonl-analytics-store.ts @@ -0,0 +1,310 @@ +import {randomUUID} from 'node:crypto' +import {mkdir, open, readFile, rename, rm, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' + +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type { + IJsonlAnalyticsStore, + JsonlAnalyticsStoreListOptions, + JsonlAnalyticsStoreListResult, + JsonlAnalyticsStoreUpdateStatus, +} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' + +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../shared/analytics/stored-record.js' + +const DEFAULT_FILE_NAME = 'analytics-queue.jsonl' +const DEFAULT_MAX_ROWS = 5000 +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 + +/** + * Thrown by `append` when the file-size cap cannot accommodate the new + * record even after dropping every available `'sent'` row. The store has + * already persisted any partial compaction and incremented + * `droppedFullCount()`; the throw signals to the caller that THIS specific + * record did NOT land on disk so it can skip mirror writes (e.g. queue + * push) and keep the JSONL=truth invariant intact. + * + * Callers that don't care still MUST catch — analytics MUST NOT crash the + * consumer. + */ +export class JsonlCapFullError extends Error { + public readonly recordId: string + + public constructor(recordId: string) { + super(`JSONL cap full: record ${recordId} dropped (no sent rows left to evict)`) + this.name = 'JsonlCapFullError' + this.recordId = recordId + } +} + +/** + * Constructor options. `baseDir` is required (caller injects + * `getGlobalDataDir()` in production; tests pass a `tmpdir()`-derived + * path). The other fields default to plan-locked values. + */ +export type JsonlAnalyticsStoreOptions = { + baseDir: string + fileName?: string + maxBytes?: number + maxRows?: number +} + +/** + * File-backed JSONL store implementation. See `IJsonlAnalyticsStore` for + * the consumer contract. + * + * Design notes: + * - Atomic rewrite for `updateStatus` and compaction: write to + * `${path}.${randomUUID()}.tmp` then `rename`. Mirrors + * `FileQueryLogStore.writeAtomic`. + * - All mutating calls (`append`, `updateStatus`) flow through a single + * `writeChain` Promise. This eliminates the `appendFile` vs + * `readFile/rename` race where a `track()`-time append could land + * between `updateStatus`'s read snapshot and rename and be silently + * overwritten. + * - Read methods (`list`, `loadPending`) do NOT enter the write chain — + * reads do not corrupt and a caller that needs strict consistency + * should sequence its own reads after its writes. + * - Retry-cap policy lives inside `updateStatus(_, 'failed')` (NOT in + * the caller). Plan-locked: increment attempts; row stays + * `'pending'` while `attempts < MAX_ATTEMPTS`; flips to terminal + * `'failed'` at the cap; no-op on rows already terminal. + */ +export class JsonlAnalyticsStore implements IJsonlAnalyticsStore { + private droppedFullCounter = 0 + private droppedSentCounter = 0 + private readonly filePath: string + private readonly maxBytes: number + private readonly maxRows: number + private writeChain: Promise = Promise.resolve() + + public constructor(opts: JsonlAnalyticsStoreOptions) { + this.filePath = join(opts.baseDir, opts.fileName ?? DEFAULT_FILE_NAME) + this.maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS + this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + } + + public async append(record: StoredAnalyticsRecord): Promise { + return this.enqueue(async () => this.doAppend(record)) + } + + public async clear(): Promise { + return this.enqueue(async () => this.atomicRewrite([])) + } + + public droppedFullCount(): number { + return this.droppedFullCounter + } + + public droppedSentCount(): number { + return this.droppedSentCounter + } + + public async list(opts: JsonlAnalyticsStoreListOptions): Promise { + const all = await this.readAllRecords() + const filtered = all.filter((row) => { + if (opts.eventName !== undefined && row.name !== opts.eventName) return false + if (opts.status !== undefined && row.status !== opts.status) return false + return true + }) + // Sort by (timestamp DESC, id DESC). Same-timestamp tie broken by id DESC for stable ordering. + filtered.sort((a, b) => { + if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp + if (a.id < b.id) return 1 + if (a.id > b.id) return -1 + return 0 + }) + const rows = filtered.slice(opts.offset, opts.offset + opts.limit) + return {rows, total: filtered.length} + } + + public async loadPending(): Promise { + const all = await this.readAllRecords() + return all.filter((r) => r.status === 'pending') + } + + public async updateStatus(ids: readonly string[], status: JsonlAnalyticsStoreUpdateStatus): Promise { + if (ids.length === 0) return + const idSet = new Set(ids) + return this.enqueue(async () => this.doUpdateStatus(idSet, status)) + } + + /** + * Atomic file rewrite via `tmp + rename`. Mirrors `FileQueryLogStore`. + * On failure, removes the tmp file and re-throws. + */ + private async atomicRewrite(rows: readonly StoredAnalyticsRecord[]): Promise { + await this.ensureDir() + const content = rows.length === 0 ? '' : rows.map((r) => JSON.stringify(r)).join('\n') + '\n' + const tmpPath = `${this.filePath}.${randomUUID()}.tmp` + try { + await writeFile(tmpPath, content, 'utf8') + await rename(tmpPath, this.filePath) + } catch (error) { + await rm(tmpPath, {force: true}).catch(() => {}) + throw error + } + } + + /** + * Drop oldest `'sent'` rows (insertion order = file order = oldest-first) + * until under cap or out of `'sent'` rows. Pending and failed are never + * dropped. Returns the kept rows + count of sent rows actually removed. + * + * Single-pass: precompute each row's serialized size once, then walk + * once decrementing running totals on each drop. The earlier + * `while exceedsCap` + `findIndex` + `splice` loop was O(n²) — each + * iteration re-stringified every row and reshuffled the array. + */ + private compactRows(rows: readonly StoredAnalyticsRecord[]): {kept: StoredAnalyticsRecord[]; sentDropped: number} { + const sizes: number[] = [] + let bytes = 0 + for (const r of rows) { + const size = Buffer.byteLength(JSON.stringify(r) + '\n', 'utf8') + sizes.push(size) + bytes += size + } + + let count = rows.length + const isOver = (): boolean => bytes > this.maxBytes || count > this.maxRows + + const kept: StoredAnalyticsRecord[] = [] + let sentDropped = 0 + for (const [i, r] of rows.entries()) { + if (isOver() && r.status === 'sent') { + bytes -= sizes[i] + count -= 1 + sentDropped++ + continue + } + + kept.push(r) + } + + return {kept, sentDropped} + } + + private async doAppend(record: StoredAnalyticsRecord): Promise { + await this.ensureDir() + const all = await this.readAllRecords() + const simulated = [...all, record] + + if (this.exceedsCap(simulated)) { + const {kept, sentDropped} = this.compactRows(simulated) + if (this.exceedsCap(kept)) { + // Even after dropping all sent rows, still over cap. Drop the new record and signal the + // caller so it can skip any mirror write (queue push). A silent return here would let + // AnalyticsClient.trackAsync diverge from disk: queue would carry an event that JSONL + // never persisted, breaking the JSONL=truth invariant. + if (sentDropped > 0) { + this.droppedSentCounter += sentDropped + // Persist whatever sent rows we did manage to drop, but exclude the new record. + await this.atomicRewrite(kept.filter((r) => r.id !== record.id)) + } + + this.droppedFullCounter++ + throw new JsonlCapFullError(record.id) + } + + // Compaction succeeded: write the compacted set (which already includes the new record). + this.droppedSentCounter += sentDropped + await this.atomicRewrite(kept) + return + } + + // Normal path: explicit fsync via FileHandle.sync() so the row survives daemon kill. + const line = JSON.stringify(record) + '\n' + const handle = await open(this.filePath, 'a') + try { + await handle.appendFile(line, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + } + + private async doUpdateStatus(idSet: Set, status: JsonlAnalyticsStoreUpdateStatus): Promise { + const all = await this.readAllRecords() + let mutated = false + const next = all.map((row): StoredAnalyticsRecord => { + if (!idSet.has(row.id)) return row + + if (status === 'sent') { + if (row.status === 'sent') return row + mutated = true + return {...row, status: 'sent'} + } + + // status === 'failed' — retry-cap gate. Skip rows already at terminal failed (no overshoot). + if (row.status === 'failed') return row + const nextAttempts = row.attempts + 1 + mutated = true + if (nextAttempts >= MAX_ATTEMPTS) { + return {...row, attempts: nextAttempts, status: 'failed'} + } + + return {...row, attempts: nextAttempts, status: 'pending'} + }) + + if (!mutated) return + await this.atomicRewrite(next) + } + + /** + * Serialize `work` against any in-flight write. Caller awaits `next` + * to observe errors from this specific call. The chain itself swallows + * errors so a failure in one `enqueue` does NOT reject all subsequent + * calls. + */ + private enqueue(work: () => Promise): Promise { + const next = this.writeChain.then(async () => work()) + this.writeChain = next.then( + () => {}, + () => {}, + ) + return next + } + + private async ensureDir(): Promise { + await mkdir(dirname(this.filePath), {recursive: true}) + } + + private exceedsCap(rows: readonly StoredAnalyticsRecord[]): boolean { + if (rows.length > this.maxRows) return true + let bytes = 0 + for (const r of rows) { + bytes += Buffer.byteLength(JSON.stringify(r) + '\n', 'utf8') + if (bytes > this.maxBytes) return true + } + + return false + } + + private async readAllRecords(): Promise { + let content: string + try { + content = await readFile(this.filePath, 'utf8') + } catch { + return [] + } + + const records: StoredAnalyticsRecord[] = [] + for (const line of content.split('\n')) { + if (line.length === 0) continue + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + // Skip unparseable line (corrupt write or partial flush). + continue + } + + const parsed = StoredAnalyticsRecordSchema.safeParse(raw) + if (parsed.success) { + records.push(parsed.data) + } + } + + return records + } +} diff --git a/src/server/infra/analytics/no-op-analytics-client.ts b/src/server/infra/analytics/no-op-analytics-client.ts new file mode 100644 index 000000000..6781ee104 --- /dev/null +++ b/src/server/infra/analytics/no-op-analytics-client.ts @@ -0,0 +1,37 @@ +import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' + +/** + * Default analytics client used by the daemon before the real client is + * wired (M2.5) and by tests that need a stand-in. `track()` is a true + * no-op — no buffering, no resolver calls; `flush()` always resolves to + * an empty batch. + */ +export class NoOpAnalyticsClient implements IAnalyticsClient { + public abort(): void { + // intentional no-op — no in-flight flush to cancel. + } + + public async flush(): Promise { + return AnalyticsBatch.create([]) + } + + public async getRuntimeState(): Promise<{ + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number + }> { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + } + + public async onAuthTransition(): Promise { + // intentional no-op + } + + public track(_event: E, ..._rest: PropsArg): void { + // intentional no-op + } +} diff --git a/src/server/infra/analytics/no-op-analytics-sender.ts b/src/server/infra/analytics/no-op-analytics-sender.ts new file mode 100644 index 000000000..85e7ef52c --- /dev/null +++ b/src/server/infra/analytics/no-op-analytics-sender.ts @@ -0,0 +1,31 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' + +/** + * Semantically inert sender. `send()` returns both arrays empty, so when + * M10.2's flush mirrors the result back to JSONL via + * `updateStatus(succeeded, 'sent')` and `updateStatus(failed, 'failed')`, + * both calls receive empty input and become no-ops. Pending JSONL rows + * stay at `status='pending'`. + * + * Returning empty arrays — rather than echoing every input id as + * `failed` — eliminates the data-loss hazard that would otherwise appear + * if the flush scheduler runs without a working sender (M4.3 scheduler + + * M4.2 HTTP sender). Scheduled ticks remain observable but non-destructive. + * + * Status (post-M4.2): no longer wired into the daemon — `HttpAnalyticsSender` + * is the production default. Kept as a test seam: `analytics-client.test.ts` + * uses it to assert the "leave-JSONL-untouched" invariant against the real + * flush wiring, and future test harnesses (e.g. M4.3 scheduler tests) can + * drop it in to isolate scheduling behavior from transport. + */ +export class NoOpAnalyticsSender implements IAnalyticsSender { + public async send(_records: readonly StoredAnalyticsRecord[]): Promise { + // M4.4 `AnalyticsSenderOptions` (signal) intentionally accepted by + // the interface but ignored here — the no-op sender never reaches a + // transport that could be cancelled. Omitting the parameter keeps + // the structural-type assignment to `IAnalyticsSender` valid (optional + // parameter). + return {failed: [], succeeded: []} + } +} diff --git a/src/server/infra/analytics/super-properties-resolver.ts b/src/server/infra/analytics/super-properties-resolver.ts new file mode 100644 index 000000000..469466d48 --- /dev/null +++ b/src/server/infra/analytics/super-properties-resolver.ts @@ -0,0 +1,55 @@ +/* eslint-disable camelcase */ +import type {ISuperPropertiesResolver, SuperProperties} from '../../core/interfaces/analytics/i-super-properties-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +import {readCliVersion} from '../../utils/read-cli-version.js' +import {getClientKindFromContext} from '../transport/client-kind-context.js' + +type StaticFields = Readonly<{ + cli_version: string + environment: 'development' | 'production' + node_version: string + os: NodeJS.Platform +}> + +/** + * Resolves the five super properties attached to every analytics event. + * + * `cli_version`, `os`, `node_version`, and `environment` are cached on + * first `resolve()` and never re-read (no static value can change at + * runtime). `device_id` is re-read from `IGlobalConfigStore` on every + * call so a swapped GlobalConfig is observable; reads are cheap and the + * value is stable in practice. + */ +export class SuperPropertiesResolver implements ISuperPropertiesResolver { + private readonly globalConfigStore: IGlobalConfigStore + private staticFields: StaticFields | undefined + private readonly versionReader: () => string + + public constructor(globalConfigStore: IGlobalConfigStore, versionReader: () => string = readCliVersion) { + this.globalConfigStore = globalConfigStore + this.versionReader = versionReader + } + + public async resolve(): Promise { + if (!this.staticFields) { + this.staticFields = { + cli_version: this.versionReader(), + environment: process.env.BRV_ENV === 'development' ? 'development' : 'production', + node_version: process.version, + os: process.platform, + } + } + + const config = await this.globalConfigStore.read() + const clientKind = getClientKindFromContext() + return { + cli_version: this.staticFields.cli_version, + ...(clientKind ? {client_kind: clientKind} : {}), + device_id: config?.deviceId ?? '', + environment: this.staticFields.environment, + node_version: this.staticFields.node_version, + os: this.staticFields.os, + } + } +} diff --git a/src/server/infra/client/client-manager.ts b/src/server/infra/client/client-manager.ts index 42008fb5b..9d2b685c5 100644 --- a/src/server/infra/client/client-manager.ts +++ b/src/server/infra/client/client-manager.ts @@ -14,11 +14,18 @@ */ import type {ClientType} from '../../core/domain/client/client-info.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IClientManager, ProjectEmptyCallback} from '../../core/interfaces/client/i-client-manager.js' +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {ClientInfo} from '../../core/domain/client/client-info.js' +import {hashProjectPath} from '../../utils/hash-path.js' +import {processLog} from '../../utils/process-logger.js' +import {clientKindContext} from '../transport/client-kind-context.js' export class ClientManager implements IClientManager { + /** Optional analytics client for M15.5 WebUI session events */ + private analyticsClient: IAnalyticsClient | undefined /** Callback for when a client registers */ private clientConnectedCallback?: () => void /** Callback for when a client unregisters */ @@ -87,6 +94,19 @@ export class ClientManager implements IClientManager { this.removeFromProjectIndex(clientId, existing.projectPath) } + // M15.5: on reconnect of a webui client, close out the prior session so + // analytics doesn't orphan an unmatched started event. + if (existing?.type === 'webui') { + this.emitWebuiSessionEnded(existing) + } + + // M15.8: same orphan-end logic for MCP. Gate on the SNAPSHOTTED start + // emit (mcpSessionEmittedName), not the live agentName — that's the only + // signal a session_start was actually emitted for this ClientInfo. + if (existing?.type === 'mcp' && existing.mcpSessionEmittedName !== undefined) { + this.emitMcpSessionEnded(existing) + } + const client = new ClientInfo({ connectedAt: Date.now(), id: clientId, @@ -99,6 +119,12 @@ export class ClientManager implements IClientManager { this.addToProjectIndex(clientId, projectPath) } + // M15.5: WebUI session lifecycle. Fires AFTER `clients.set` so any + // analytics-side hook can still look the client up by id. + if (type === 'webui') { + this.emitWebuiSessionStarted(client) + } + // Only notify idle timeout policy for new clients, not re-registrations. // Re-registrations replace the existing entry without unregister, so firing // clientConnectedCallback again would desync IdleTimeoutPolicy.clientCount. @@ -111,13 +137,41 @@ export class ClientManager implements IClientManager { const client = this.clients.get(clientId) if (!client) return + // M15.8: mcp_session_start fires on the FIRST handshake (agentName + // transitions from undefined → defined). Re-handshakes (same id, name + // already set) stay idempotent and do not re-emit. + const wasFirstMcpHandshake = client.type === 'mcp' && client.agentName === undefined client.setAgentName(agentName) + if (wasFirstMcpHandshake) { + this.emitMcpSessionStarted(client) + } + } + + /** + * M15.5: register the analytics client. Setter pattern because + * ClientManager is constructed in brv-server.ts before analyticsClient + * exists (which is built inside setupFeatureHandlers). + */ + setAnalyticsClient(client: IAnalyticsClient): void { + this.analyticsClient = client } unregister(clientId: string): void { const client = this.clients.get(clientId) if (!client) return + // M15.5: emit BEFORE clients.delete so we can still read client.type / + // .connectedAt / .projectPath. + if (client.type === 'webui') { + this.emitWebuiSessionEnded(client) + } + + // M15.8: MCP ended fires only if a session_start was previously emitted + // for this ClientInfo (snapshot field set). No start → no end. + if (client.type === 'mcp' && client.mcpSessionEmittedName !== undefined) { + this.emitMcpSessionEnded(client) + } + this.clients.delete(clientId) if (client.projectPath) { @@ -178,6 +232,125 @@ export class ClientManager implements IClientManager { } } + /** + * M15.8: emit mcp_session_ended. Mirrors emitWebuiSessionEnded. Fires on + * unregister and on reconnect orphan-end, only when a prior session-start + * was emitted for this ClientInfo (snapshot field set). Reads the SAME + * client_name the start event carried — never `client.agentName` directly, + * so future mid-session `setAgentName` mutations can't desync start/end. + */ + private emitMcpSessionEnded(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + const emittedName = client.mcpSessionEmittedName + if (emittedName === undefined) return + const sessionDurationMs = Math.max(0, Date.now() - client.connectedAt) + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'mcp'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.MCP_SESSION_ENDED, { + client_name: emittedName, + session_duration_ms: sessionDurationMs, + started_at_unix_ms: client.connectedAt, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track mcp_session_ended failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + + /** + * M15.8: emit mcp_session_start. Fires from setAgentName when the + * MCP `oninitialized` handshake delivers the IDE product name — the + * Socket.IO connect itself precedes the handshake, so register() time + * is too early. Snapshots the emitted name onto ClientInfo so the + * matching mcp_session_ended reads the same value. + */ + private emitMcpSessionStarted(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + const {agentName} = client + if (agentName === undefined) return + // Freeze the about-to-be-emitted name BEFORE track(). Even if track() + // throws, future mid-session agentName mutations can't change what + // emitMcpSessionEnded would emit (it reads the snapshot). + client.markMcpSessionStartEmitted(agentName) + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'mcp'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.MCP_SESSION_START, { + client_name: agentName, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track mcp_session_start failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + + /** + * M15.5: emit webui_session_ended. Wrapped in clientKindContext so + * SuperPropertiesResolver stamps client_kind='webui' on the envelope + * (daemon-internal emit bypasses the transport wrap). try/processLog + * pattern so analytics failures never block connection bookkeeping. + */ + private emitWebuiSessionEnded(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + // Clamp at 0 to defend against clock skew (e.g. NTP adjustment between + // register and unregister). The schema enforces `nonnegative()`; a + // negative value would otherwise leak through this direct-track path + // (which bypasses the wire-side safeParse in AnalyticsHandler). + const sessionDurationMs = Math.max(0, Date.now() - client.connectedAt) + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'webui'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_ENDED, { + ...(client.projectPath === undefined ? {} : {project_path_hash: hashProjectPath(client.projectPath)}), + session_duration_ms: sessionDurationMs, + started_at_unix_ms: client.connectedAt, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track webui_session_ended failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + + /** + * M15.5: emit webui_session_started. See emitWebuiSessionEnded for the + * clientKindContext rationale. + */ + private emitWebuiSessionStarted(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'webui'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_STARTED, { + ...(client.projectPath === undefined ? {} : {project_path_hash: hashProjectPath(client.projectPath)}), + started_at_unix_ms: client.connectedAt, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track webui_session_started failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + private removeFromProjectIndex(clientId: string, projectPath: string): void { const members = this.projectClients.get(projectPath) if (!members) return diff --git a/src/server/infra/daemon/agent-process.ts b/src/server/infra/daemon/agent-process.ts index 09078e229..72c72ddab 100644 --- a/src/server/infra/daemon/agent-process.ts +++ b/src/server/infra/daemon/agent-process.ts @@ -69,6 +69,10 @@ import {FolderPackExecutor} from '../executor/folder-pack-executor.js' import {QueryExecutor} from '../executor/query-executor.js' import {SearchExecutor} from '../executor/search-executor.js' import {backupContextTreeFile, buildCurateHtmlLogEntry} from '../process/curate-html-log.js' +import { + emitSyntheticCurateToolResult, + emitSyntheticQueryToolCalls, +} from '../process/synthetic-tool-result-emit.js' import {validateHtmlTopic, writeHtmlTopic} from '../render/writer/html-writer.js' import {FileCurateLogStore} from '../storage/file-curate-log-store.js' import {FileReviewBackupStore} from '../storage/file-review-backup-store.js' @@ -759,26 +763,27 @@ async function executeTask( topicPathResolved = preValidation.topicPath } + const curateLogStore = new FileCurateLogStore({baseDir: storagePath}) + const entryId = await curateLogStore.getNextId() + const logEntry = buildCurateHtmlLogEntry({ + completedAt, + confirmOverwrite: Boolean(confirmOverwrite), + existedBefore, + // Absolute path — the review-handler treats `op.filePath` as + // absolute and calls `relative(contextTreeDir, ...)` to derive + // a display key. Storing a relative path here makes the entry + // unmatchable in `brv review approve`. + filePath: writeResult.ok ? writeResult.filePath : undefined, + id: entryId, + meta, + reviewDisabled: reviewDisabled ?? false, + startedAt, + taskId, + topicPath: topicPathResolved, + writeResult, + }) + try { - const curateLogStore = new FileCurateLogStore({baseDir: storagePath}) - const entryId = await curateLogStore.getNextId() - const logEntry = buildCurateHtmlLogEntry({ - completedAt, - confirmOverwrite: Boolean(confirmOverwrite), - existedBefore, - // Absolute path — the review-handler treats `op.filePath` as - // absolute and calls `relative(contextTreeDir, ...)` to derive - // a display key. Storing a relative path here makes the entry - // unmatchable in `brv review approve`. - filePath: writeResult.ok ? writeResult.filePath : undefined, - id: entryId, - meta, - reviewDisabled: reviewDisabled ?? false, - startedAt, - taskId, - topicPath: topicPathResolved, - writeResult, - }) await curateLogStore.save(logEntry) logId = entryId } catch (error) { @@ -790,6 +795,19 @@ async function executeTask( ) } + // M17: synthetic llmservice:toolResult so AnalyticsHook + + // CurateLogHandler fire `curate_operation_applied` and bump + // `curate_run_completed.operations_*` (the legacy LLM-driven + // path emitted this event; tool-mode has to forge it). + if (transport) { + emitSyntheticCurateToolResult({ + log: agentLog, + operations: logEntry.operations, + taskId, + transport, + }) + } + // Regenerate the context-tree index so the new topic appears in // index.html. Deferred to postWorkRegistry (drained below): it // runs after task:completed — off the user-facing latency path — @@ -1016,6 +1034,22 @@ async function executeTask( }) result = JSON.stringify(toolModeResult) + // M17: synthetic llmservice:toolCall events so AnalyticsHook + // populates query_completed counters + read_paths_with_metadata + // (the legacy LLM path emitted these via real read_file / + // search_knowledge tool calls; tool-mode runs deterministic + // BM25 server-side and emits zero LLM events on its own). + if (transport) { + emitSyntheticQueryToolCalls({ + log: agentLog, + matchedDocs: toolModeResult.matchedDocs, + metadata: toolModeResult.metadata, + projectPath, + taskId, + transport, + }) + } + break } diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index ba8fc746e..2cabbcd16 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -25,12 +25,14 @@ import {GlobalInstanceManager} from '@campfirein/brv-transport-client' import express from 'express' import {fork, type StdioOptions} from 'node:child_process' -import {mkdirSync, readdirSync, readFileSync, unlinkSync} from 'node:fs' +import {mkdirSync, readdirSync, unlinkSync} from 'node:fs' import {dirname, join} from 'node:path' import {fileURLToPath} from 'node:url' import type {BrvConfig} from '../../core/domain/entities/brv-config.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {ReviewEvents} from '../../../shared/transport/events/review-events.js' import {TaskEvents, type TaskHeartbeatEvent} from '../../../shared/transport/events/task-events.js' import { @@ -51,10 +53,12 @@ import { import {buildReviewUrl} from '../../utils/build-review-url.js' import {getGlobalDataDir} from '../../utils/global-data-path.js' import {crashLog, processLog} from '../../utils/process-logger.js' +import {readCliVersion} from '../../utils/read-cli-version.js' import {createBillingStateHandler} from '../billing/billing-state-endpoint.js' import {ClientManager} from '../client/client-manager.js' import {ProjectConfigStore} from '../config/file-config-store.js' import {readContextTreeRemoteUrl} from '../context-tree/read-context-tree-remote.js' +import {AnalyticsHook} from '../process/analytics-hook.js' import {broadcastToProjectRoom} from '../process/broadcast-utils.js' import {CurateLogHandler} from '../process/curate-log-handler.js' import {setupFeatureHandlers} from '../process/feature-handlers.js' @@ -75,6 +79,7 @@ import {FileProviderConfigStore} from '../storage/file-provider-config-store.js' import {FileSettingsStore} from '../storage/file-settings-store.js' import {createProviderKeychainStore} from '../storage/provider-keychain-store.js' import {createTokenStore} from '../storage/token-store.js' +import {attachCliInvocationMiddleware} from '../transport/cli-invocation-middleware.js' import {SocketIOTransportServer} from '../transport/socket-io-transport-server.js' import {createWebUiMiddleware} from '../webui/webui-middleware.js' import {WebUiServer} from '../webui/webui-server.js' @@ -97,25 +102,6 @@ function log(msg: string): void { processLog(`[Daemon] ${msg}`) } -/** - * Reads the CLI version from package.json. - * Walks up from the compiled file location to find the project root. - */ -function readCliVersion(): string { - try { - const currentDir = dirname(fileURLToPath(import.meta.url)) - // Both src/ and dist/ are 4 levels deep: server/infra/daemon/brv-server - const pkgPath = join(currentDir, '..', '..', '..', '..', 'package.json') - const pkg: unknown = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (typeof pkg === 'object' && pkg !== null && 'version' in pkg && typeof pkg.version === 'string') { - return pkg.version - } - } catch { - // Best-effort — return fallback - } - - return 'unknown' -} /** * Removes old daemon log files, keeping the most recent ones. @@ -197,10 +183,22 @@ async function main(): Promise { let agentPool: AgentPool | undefined let webuiServer: undefined | WebUiServer + // M15.8 §4 — lazy holder for the analyticsClient. The CLI-invocation + // middleware is attached to the transport server BEFORE setupFeatureHandlers + // constructs the analytics client; this reference is reseated when the + // client lands so the middleware can start emitting. + let analyticsClientRef: IAnalyticsClient | undefined + try { // 4a. Construct transport server. start() is deferred to step 11 so all handlers register before sockets connect. transportServer = new SocketIOTransportServer() + // M15.8 §4 — attach cli_invocation middleware BEFORE any handler is + // registered so every incoming request flows through the wrap. The + // analytics client is not constructed yet; the lazy getter resolves it + // once setupFeatureHandlers below sets analyticsClientRef. + attachCliInvocationMiddleware(transportServer, {getAnalyticsClient: () => analyticsClientRef}) + // 4b. Start Web UI server on stable port (separate from transport) const daemonDir = dirname(fileURLToPath(import.meta.url)) const projectRoot = join(daemonDir, '..', '..', '..', '..') @@ -263,6 +261,15 @@ async function main(): Promise { const projectRouter = new ProjectRouter({transport: transportServer}) const clientManager = new ClientManager() + // Stamp `client_kind` on analytics super-properties for every request + // originating from a registered Socket.IO client. Agent-fork + // connections bypass the wrap (return undefined) so daemon-internal + // task lifecycle emits stay envelope-clean. + transportServer.setGetClientKind((clientId) => { + const type = clientManager.getClient(clientId)?.type + return type && type !== 'agent' ? type : undefined + }) + authStateStore = new AuthStateStore({log, tokenStore: createTokenStore()}) const projectStateLoader = new ProjectStateLoader({ configStore: new ProjectConfigStore(), @@ -358,6 +365,23 @@ async function main(): Promise { // same instances this hook writes to. const taskHistoryHook = new TaskHistoryHook({getStore: getTaskHistoryStore}) + // M15.6: AnalyticsHook is the 4th lifecycle peer alongside curate-log / + // query-log / task-history. It emits task_created / task_completed / + // task_failed (and M12 per-flavor events for curate / query) into the + // daemon's IAnalyticsClient. The client + isAnalyticsEnabled gate come + // from setupFeatureHandlers later in this function; the closure below + // defers the lookup so the hook can be constructed in time to land in + // lifecycleHooks[] but still observe the live config. + let isAnalyticsEnabledRef: () => boolean = () => true + const analyticsHook = new AnalyticsHook({ + async getIdentity(projectPath) { + if (!projectPath) return {} + const config = await projectStateLoader.getProjectConfig(projectPath) + return {spaceId: config?.spaceId, teamId: config?.teamId} + }, + isEnabled: () => isAnalyticsEnabledRef(), + }) + // Provider config/keychain stores — shared between feature handlers and state endpoint. // Hoisted ahead of `new TransportHandlers` so the resolveActiveProvider callback below // can close over them and call resolveProviderConfig synchronously at task-create time. @@ -418,7 +442,7 @@ async function main(): Promise { const config = await new ProjectConfigStore().read(projectPath) return config?.reviewDisabled === true }, - lifecycleHooks: [curateLogHandler, queryLogHandler, taskHistoryHook], + lifecycleHooks: [curateLogHandler, queryLogHandler, taskHistoryHook, analyticsHook], projectRegistry, projectRouter, // Stamp the active provider/model snapshot onto every created task so the @@ -486,10 +510,21 @@ async function main(): Promise { }, }) + // M4.3: the analytics flush scheduler is constructed inside + // setupFeatureHandlers (later), so the final-flush closure resolves + // through a mutable holder. The shutdown sequence calls this hook + // after the agent pool stops; if setupFeatureHandlers never ran + // (e.g. startup crashed early) the holder stays undefined and the + // hook is skipped. Restored by M15.6 follow-up — the original M4.3 + // wiring got dropped when the late-bind for AnalyticsHook landed. + // eslint-disable-next-line prefer-const + let analyticsFinalFlush: (() => Promise) | undefined + // 9. Create shutdown handler (agent pool shut down before transport) shutdownHandler = new ShutdownHandler({ agentIdleTimeoutPolicy, agentPool, + analyticsFinalFlush: () => analyticsFinalFlush?.() ?? Promise.resolve(), daemonResilience, heartbeatWriter, idleTimeoutPolicy, @@ -633,12 +668,13 @@ async function main(): Promise { // Feature handlers (auth, init, status, push, pull, etc.) require async OIDC discovery. // Placed after daemon:getState so the debug endpoint is available immediately, // without waiting for OIDC discovery (~400ms). - await setupFeatureHandlers({ + const featureHandlers = await setupFeatureHandlers({ authStateStore, billingConfigStoreFactory, broadcastToProject(projectPath, event, data) { broadcastToProjectRoom(projectRegistry, projectRouter, projectPath, event, data) }, + clientManager, getActiveProjectPaths: () => clientManager.getActiveProjects(), log, projectRegistry, @@ -651,6 +687,36 @@ async function main(): Promise { webuiPort: webuiServer?.getPort(), }) + // M15.6: now that setupFeatureHandlers has constructed the real + // IAnalyticsClient + isAnalyticsEnabled callback, late-bind them into + // the AnalyticsHook that was pre-registered in lifecycleHooks[]. Any + // task_* emits queued during the boot window between hook construction + // and this line silently no-op (matches `setAnalyticsClient`'s docblock + // contract — no tasks are active during daemon boot). + isAnalyticsEnabledRef = featureHandlers.isAnalyticsEnabled + // PR #722 review: explode loudly if a future refactor drops + // analyticsClient from the result shape — silently no-op'ing every + // emit forever is the worst failure mode for telemetry plumbing. + if (!featureHandlers.analyticsClient) { + throw new Error('setupFeatureHandlers returned without analyticsClient — AnalyticsHook cannot bind') + } + + analyticsHook.setAnalyticsClient(featureHandlers.analyticsClient) + // M15.8 §4 — seat the cli_invocation middleware's analytics-client ref + // so the wrap (attached at line 211) starts emitting on subsequent requests. + analyticsClientRef = featureHandlers.analyticsClient + + // M4.3: start the flush scheduler AFTER the first track lands so the + // initial 30s window aligns with real traffic, and wire the shutdown + // hook now that the scheduler exists. Hook stops the scheduler first + // (no new ticks mid-shutdown) before awaiting the best-effort final + // flush against a 3s budget. + featureHandlers.analyticsFlushScheduler.start() + analyticsFinalFlush = async () => { + featureHandlers.analyticsFlushScheduler.stop() + await featureHandlers.analyticsFlushScheduler.flushFinal({timeoutMs: 3000}) + } + // Load auth token AFTER feature handlers are registered. // AuthHandler's onAuthChanged/onAuthExpired callbacks must be wired first // so that loadToken() triggers proper broadcasts to TUI and agents. @@ -658,6 +724,15 @@ async function main(): Promise { await authStateStore.loadToken() authStateStore.startPolling() + // M15.8: emit daemon_start AFTER loadToken() so the event's identity + // envelope reflects the just-resolved auth state (anon vs authed). + // Wrapped in try so a future track-time failure cannot abort boot. + try { + featureHandlers.analyticsClient.track(AnalyticsEventNames.DAEMON_START) + } catch (error: unknown) { + log(`daemon_start emit failed: ${error instanceof Error ? error.message : String(error)}`) + } + // 11. Start idle timer + register signal handlers idleTimeoutPolicy.start() diff --git a/src/server/infra/daemon/shutdown-handler.ts b/src/server/infra/daemon/shutdown-handler.ts index 65867a3a6..f239206d1 100644 --- a/src/server/infra/daemon/shutdown-handler.ts +++ b/src/server/infra/daemon/shutdown-handler.ts @@ -19,6 +19,14 @@ interface IWebUiServer { export interface ShutdownHandlerDeps { readonly agentIdleTimeoutPolicy?: IAgentIdleTimeoutPolicy readonly agentPool?: IAgentPool + /** + * M4.3: best-effort final analytics flush. Invoked after the agent pool + * stops (no more new events) and before the transport server stops so + * the daemon ships any queued events before the network goes down. + * The hook owns its own timeout — the shutdown sequence does not block + * for more than the hook itself permits. + */ + readonly analyticsFinalFlush?: () => Promise readonly daemonResilience: IDaemonResilience readonly heartbeatWriter: IHeartbeatWriter readonly idleTimeoutPolicy: IIdleTimeoutPolicy @@ -112,6 +120,20 @@ export class ShutdownHandler implements IShutdownHandler { } } + // Step 5.5. Final analytics flush (M4.3). Best-effort: the hook owns + // its own timeout so the shutdown sequence cannot stall on a slow + // telemetry backend. Runs AFTER agent pool stops (no new events + // arrive) and BEFORE transport stops (analytics uses axios, not + // transport, so the ordering is for invariant clarity rather than + // correctness — keeps "no daemon services are stopped" intuition). + if (this.deps.analyticsFinalFlush) { + try { + await this.deps.analyticsFinalFlush() + } catch (error) { + log(`Error during final analytics flush: ${error instanceof Error ? error.message : String(error)}`) + } + } + // Step 6. Stop transport server (disconnect all sockets, close HTTP) // Wrapped in Promise.race with timeout to prevent hanging — if Socket.IO // blocks (e.g., waiting for in-flight responses), we proceed with remaining diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts new file mode 100644 index 000000000..9d0c036ea --- /dev/null +++ b/src/server/infra/process/analytics-hook.ts @@ -0,0 +1,793 @@ +/* eslint-disable camelcase */ +import {readFile as readFileAsync} from 'node:fs/promises' +import {isAbsolute as isAbsolutePath, relative as relativePath} from 'node:path' + +import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' +import type {CurateRunCompletedProps} from '../../../shared/analytics/events/curate-run-completed.js' +import type {PropsArg} from '../../../shared/analytics/events/index.js' +import type {QueryCompletedProps} from '../../../shared/analytics/events/query-completed.js' +import type {FailureKind} from '../../../shared/analytics/events/task-failed.js' +import type {TaskType} from '../../../shared/analytics/task-types.js' +import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' +import type {TaskInfo} from '../../core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js' +import type {QueryResultMetadata} from './query-log-handler.js' + +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' +import {TaskTypes} from '../../../shared/analytics/task-types.js' +import {parseFrontmatter} from '../../core/domain/knowledge/markdown-writer.js' +import {extractCurateOperations} from '../../utils/curate-result-parser.js' +import {hashProjectPath} from '../../utils/hash-path.js' +import {processLog} from '../../utils/process-logger.js' +import {readHtmlTopicSync} from '../render/reader/html-reader.js' +import {CURATE_TASK_TYPES} from './curate-log-handler.js' +import {QUERY_TASK_TYPES} from './query-log-handler.js' + +/** + * Set of canonical task types accepted by the wire schema. Membership check + * runs in `toAnalyticsTaskType` to gate emits against the daemon dispatching + * a string TASK_TYPE_VALUES doesn't enumerate. + */ +const ANALYTICS_TASK_TYPE_SET: ReadonlySet = new Set(Object.values(TaskTypes) as TaskType[]) + +const isCanonicalTaskType = (value: string): value is TaskType => (ANALYTICS_TASK_TYPE_SET as Set).has(value) + +/** + * Translate the daemon's runtime task type string to the canonical + * analytics wire value. The daemon still dispatches the pre-ENG-2925 + * name `'curate-html-direct'`; analytics emits the post-rename + * `'curate-tool-mode'`. Once the rename PR lands, the alias becomes + * dead code and can be inlined. + * + * Drift guard (PR #722 review re-review): if the daemon dispatches a + * type that isn't enumerated in `TASK_TYPE_VALUES`, fall back to + * `TaskTypes.UNKNOWN` (which is in the wire vocabulary, so the backend + * accepts the row) and log a daemon-side breadcrumb. The previous + * implementation cast a non-enumerated string back to `TaskType`, + * which silently failed the backend Zod check. + */ +function toAnalyticsTaskType(daemonType: string): TaskType { + if (daemonType === 'curate-html-direct') return TaskTypes.CURATE_TOOL_MODE + if (isCanonicalTaskType(daemonType)) return daemonType + processLog(`AnalyticsHook: unknown task type '${daemonType}' — falling back to '${TaskTypes.UNKNOWN}'`) + return TaskTypes.UNKNOWN +} + +/** + * M15.8 — map a daemon task type to its MCP tool name. Returns undefined + * for any task that is not an MCP tool-mode flavor; callers gate emit on + * the returned value being defined. + */ +function mcpToolNameForTaskType(daemonType: string): 'brv-curate' | 'brv-query' | undefined { + if (daemonType === TaskTypes.QUERY_TOOL_MODE) return 'brv-query' + if (daemonType === TaskTypes.CURATE_TOOL_MODE) return 'brv-curate' + return undefined +} + +/** + * Stable sentinel for paths that can't be safely emitted as project- + * relative — either outside the project root or the project root itself + * is unknown. The backend can group these without leaking host layout. + */ +const OUTSIDE_PROJECT_PATH = '' + +/** + * Convert an absolute filesystem path to a project-relative path for the + * analytics wire. Keeps emits free of `/Users/{name}` PII while still + * letting PMs reason about which file inside a project an operation touched. + * + * PR #722 review: `path.relative('/proj', '/Users/dev/other/x.md')` yields + * `'../../Users/dev/other/x.md'` — still encodes the host layout. When the + * relative path escapes the project root (or projectPath is unset), collapse + * to a bare sentinel rather than the raw absolute path. + * + * PR #726 review: the basename of an out-of-project file is itself PII (e.g. + * `passwords.md`, `client-acme.md`, `interview-john-doe.md`) and carries + * little analytical value — the file is outside the project being measured. + * So the leaf token is dropped too: only the fact (and count) of an + * outside-project read survives the wire, never its identity. + */ +function toRelativePath(filePath: string, projectPath?: string): string { + if (!projectPath) return OUTSIDE_PROJECT_PATH + const rel = relativePath(projectPath, filePath) + // `path.relative` returns '' when paths are identical — defensively + // surface a leaf token rather than emit a zero-length wire string that + // would fail `z.string().min(1)`. + if (rel === '') return '.' + // Anything that escapes the project root (`../foo`) or stays absolute + // (Windows drive letter switches) is treated as outside-project. + if (rel.startsWith('..') || isAbsolutePath(rel)) { + return OUTSIDE_PROJECT_PATH + } + + return rel +} + +/** + * Classify a daemon-side error message into a coarse failure_kind tag. + * + * Precedence (PR #722 review — pinned so the if-order can't silently rebucket + * the funnel later): `timeout` > `agent_error` > `unknown`. A message + * containing both `'timeout'` and `'agent'` classifies as `'timeout'`. + * + * Word-boundary matching keeps unrelated tokens (`'tooltip'`, `'engagement'`, + * `'urgent'`) from bumping into the `agent_error` bucket. The raw message + * NEVER ends up on the analytics wire — only the canonical tag. + */ +const TIMEOUT_PATTERN = /\b(timeout|timed out|deadline exceeded)\b/ +const AGENT_ERROR_PATTERN = /\b(agent|llm|provider|tool)\b/ +function classifyFailureKind(errorMessage: string): FailureKind { + const m = errorMessage.toLowerCase() + if (TIMEOUT_PATTERN.test(m)) return 'timeout' + if (AGENT_ERROR_PATTERN.test(m)) return 'agent_error' + return 'unknown' +} + +// `CURATE_TASK_TYPES` is exported as a readonly tuple; wrap in a Set +// for cast-free `.has()` lookups against TaskInfo.type (string). +const CURATE_TASK_TYPE_SET: ReadonlySet = new Set(CURATE_TASK_TYPES) + +const READ_FILE_TOOL = 'read_file' +const EXPAND_KNOWLEDGE_TOOL = 'expand_knowledge' +const SEARCH_KNOWLEDGE_TOOL = 'search_knowledge' + +const MAX_READ_PATHS = 10 +const MAX_FRONTMATTER_ARRAY_LENGTH = 50 +const MAX_FRONTMATTER_STRING_LENGTH = 256 + +type FrontmatterFields = { + keywords?: string[] + related?: string[] + tags?: string[] +} + +/** + * M17 follow-up: project-scoped join key for the task / curate / query + * funnel events. Mirrors the convention every other handler-emitted + * event uses (vc-*, review-*, source-*, worktree-*, brv-init, + * context-tree-file-edited, webui-session-*). Returns `{}` when the + * project path is unset so the spread omits the field — schemas declare + * `project_path_hash` as optional for that reason. + */ +function projectPathHashOptional(projectPath: string | undefined): {project_path_hash?: string} { + if (typeof projectPath !== 'string' || projectPath.length === 0) return {} + return {project_path_hash: hashProjectPath(projectPath)} +} + +/** + * Clip a frontmatter array to schema caps: array length <= 50, per-entry + * string length <= 256. Returns `undefined` when the input is not an array + * or is empty (so the emit can OMIT the field instead of carrying `[]`). + */ +function capStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const strings: string[] = [] + for (const entry of value) { + if (typeof entry !== 'string') continue + strings.push(entry.length > MAX_FRONTMATTER_STRING_LENGTH ? entry.slice(0, MAX_FRONTMATTER_STRING_LENGTH) : entry) + if (strings.length >= MAX_FRONTMATTER_ARRAY_LENGTH) break + } + + return strings.length > 0 ? strings : undefined +} + +type CurateTaskTypeLiteral = (typeof CURATE_TASK_TYPES)[number] + +type CurateCounters = { + added: number + deleted: number + failed: number + merged: number + pendingReview: number + updated: number +} + +type CurateTaskAnalyticsState = { + counters: CurateCounters + flavor: 'curate' + /** Captured at onTaskCreate so onToolResult emits can relativize op.filePath. */ + projectPath?: string + taskType: CurateTaskTypeLiteral +} + +type QueryTaskAnalyticsState = { + flavor: 'query' + queryMeta?: QueryResultMetadata +} + +type TaskAnalyticsState = CurateTaskAnalyticsState | QueryTaskAnalyticsState + +const isCurateLiteral = (value: string): value is CurateTaskTypeLiteral => + CURATE_TASK_TYPE_SET.has(value) + +/** + * Bundle of project-scoped identity fields stamped on terminal emits. + * Each field is independently optional — a project may have a teamId + * without a spaceId (mid-onboarding) or neither (standalone). + */ +type ProjectIdentity = { + spaceId?: string + teamId?: string +} + +type AnalyticsHookDeps = { + /** + * Look up the Context Hub identity (space_id + team_id) for `projectPath` + * at emit time. Returns `{}` when the project is unconnected, the lookup + * fails, or the daemon couldn't resolve a project path — missing identity + * fields NEVER block an emit. Production wires through + * `projectStateLoader.getProjectConfig` in `brv-server.ts`; tests default + * to a no-op that always returns `{}`. + * + * Bundled (instead of one accessor per field) so a single config read + * serves both stamps at terminal time. + * + * Staleness contract: `projectStateLoader` caches the config in-process + * and only invalidates when `GET_PROJECT_CONFIG` fires (agent-process + * startup). If `.brv/config.json` is rewritten mid-session by `brv login` + * or `brv space switch`, this accessor will keep returning the + * last-known-good identity until the next invalidation. That is the + * accepted contract for funnel analytics — last-known-good is fine. + * Do NOT reuse this accessor for billing or audit attribution without + * routing through `shouldInvalidate`. + */ + getIdentity?: (projectPath: string | undefined) => Promise + /** + * Returns the daemon's cached analytics-enabled flag. Used by M12.3 to + * short-circuit frontmatter file reads when analytics is disabled (avoids + * wasted disk I/O on top of the no-op `track()`). Defaults to `() => true` + * in tests; production wires `() => globalConfigHandler.getCachedAnalytics()`. + */ + isEnabled?: () => boolean + /** + * Async file reader. Defaults to `node:fs/promises.readFile`. Injectable + * so unit tests can stub disk timing without touching the real filesystem + * (the per-task serialization tests in `analytics-hook.test.ts` rely on + * controlled `Deferred` promises here). + */ + readFile?: (filePath: string, encoding: 'utf8') => Promise +} + +/** + * Lifecycle hook that emits per-task analytics (curate_operation_applied, + * curate_run_completed, query_completed) into the daemon's + * `IAnalyticsClient`. Pure in-memory state keyed by `taskId`; no I/O of its own. + * + * Wired as a peer to `CurateLogHandler` / `QueryLogHandler` / + * `TaskHistoryHook` inside `TaskRouter.lifecycleHooks[]`. Does NOT modify the + * other handlers — read paths and curate-op accumulators are recomputed here + * via the shared `extractCurateOperations` parser and `task.toolCalls[]` + * shape, so analytics emit is decoupled from log persistence. + * + * M12.2 emits skeleton payloads (no frontmatter harvest). M12.3 layers + * `tags` / `keywords` / `related` arrays onto the curate-op and per-read-path + * payloads via a daemon-side post-op file read. + */ +export class AnalyticsHook implements ITaskLifecycleHook { + /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ + private analyticsClient?: IAnalyticsClient + private readonly getIdentity: (projectPath: string | undefined) => Promise + private readonly isEnabled: () => boolean + /** + * Per-task FIFO of in-flight `onToolResult` processing. Without this the + * naive async refactor would let concurrent TOOL_RESULT events for the + * SAME task interleave their reads + emits (socket.io does NOT serialize + * async listener invocations). The map holds a NEVER-REJECTING chain so a + * thrown read in one op cannot poison subsequent ops on the same task. + * Drained by terminal hooks (`onTaskCompleted` / `dispatchTerminal`) + * before the run-completion emit goes out, then removed in `cleanup()`. + */ + private readonly pendingByTask = new Map>() + private readonly readFile: (filePath: string, encoding: 'utf8') => Promise + /** In-memory state per active task. Cleared on cleanup(). */ + private readonly tasks = new Map() + + constructor(deps: AnalyticsHookDeps = {}) { + this.getIdentity = deps.getIdentity ?? (async (): Promise => ({})) + this.isEnabled = deps.isEnabled ?? (() => true) + this.readFile = deps.readFile ?? readFileAsync + } + + cleanup(taskId: string): void { + this.tasks.delete(taskId) + this.pendingByTask.delete(taskId) + } + + async onTaskCancelled(taskId: string, task: TaskInfo): Promise { + await this.dispatchTerminal(taskId, task, 'cancelled') + this.emitTaskFailed(taskId, task, 'cancelled') + // M15.8 — surface MCP cancellation in the dedicated funnel. The schema + // has only `success: boolean`; user-cancel is a not-completed call, so + // it shares the failure bucket with onTaskError. Without this emit the + // MCP funnel would under-count by the cancellation rate. + this.emitMcpToolCalled(task, false) + } + + async onTaskCompleted(taskId: string, _result: string, task: TaskInfo): Promise { + const state = this.tasks.get(taskId) + if (state) { + // Drain any in-flight per-op processing so CURATE_OPERATION_APPLIED emits + // land BEFORE the run-completion emit on the wire. The chain never + // rejects (see `onToolResult`), so this await is safe. + await this.pendingByTask.get(taskId) + + if (state.flavor === 'curate') { + const outcome = state.counters.failed > 0 ? 'partial' : 'completed' + const identity = await this.resolveIdentity(task.projectPath ?? state.projectPath) + this.emit( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + this.buildCurateRunPayload({identity, outcome, state, task, taskId}), + ) + } else { + const identity = await this.resolveIdentity(task.projectPath) + this.emit( + AnalyticsEventNames.QUERY_COMPLETED, + await this.buildQueryCompletedPayload({identity, outcome: 'completed', state, task, taskId}), + ) + } + } + + + // M14.3 generic funnel emit. Fires for EVERY task type AFTER any + // per-flavor M12 emit (terminal-event-last convention). + this.emit(AnalyticsEventNames.TASK_COMPLETED, { + duration_ms: this.durationMs(task), + ...projectPathHashOptional(task.projectPath), + task_id: taskId, + task_type: toAnalyticsTaskType(task.type), + }) + + // M15.8 — dedicated MCP funnel emit. Fires alongside (not instead of) + // TASK_COMPLETED; MCP volume is low so the dual-event cost is accepted. + this.emitMcpToolCalled(task, true) + } + + async onTaskCreate(task: TaskInfo): Promise { + // M14.3 generic funnel-entry emit. Fires for EVERY task type BEFORE + // the M12 per-flavor state init so the entry event lands even if + // state setup throws downstream. + this.emit(AnalyticsEventNames.TASK_CREATED, { + has_files: (task.files?.length ?? 0) > 0, + has_folder: typeof task.folderPath === 'string' && task.folderPath.length > 0, + ...projectPathHashOptional(task.projectPath), + task_id: task.taskId, + task_type: toAnalyticsTaskType(task.type), + }) + + if (isCurateLiteral(task.type)) { + this.tasks.set(task.taskId, { + counters: {added: 0, deleted: 0, failed: 0, merged: 0, pendingReview: 0, updated: 0}, + flavor: 'curate', + projectPath: task.projectPath, + taskType: task.type, + }) + return + } + + if (QUERY_TASK_TYPES.has(task.type)) { + this.tasks.set(task.taskId, {flavor: 'query'}) + } + } + + async onTaskError(taskId: string, errorMessage: string, task: TaskInfo): Promise { + await this.dispatchTerminal(taskId, task, 'error') + this.emitTaskFailed(taskId, task, classifyFailureKind(errorMessage)) + // M15.8 — surface MCP failure path in the dedicated funnel. + this.emitMcpToolCalled(task, false) + } + + async onToolResult(taskId: string, payload: LlmToolResultEvent): Promise { + // Chain onto any in-flight processing for THIS task so: + // 1. Per-op CURATE_OPERATION_APPLIED emits land in arrival order, + // even when a later op's read settles before an earlier op's read. + // 2. The terminal emit (drained via pendingByTask.get(taskId) in + // onTaskCompleted / dispatchTerminal) observes ALL per-op emits. + // The map stores a never-rejecting tail (`.catch(() => {})`) so a + // failure in one onToolResult cannot poison subsequent ones — but the + // returned `next` preserves rejection so the caller observes its own + // error (TaskRouter logs it). + const prev = this.pendingByTask.get(taskId) ?? Promise.resolve() + const next = prev.then(async () => this.processToolResult(taskId, payload)) + this.pendingByTask.set( + taskId, + next.catch(() => {}), + ) + await next + } + + /** + * Wired by the daemon factory after `setupFeatureHandlers` constructs + * the real `IAnalyticsClient`. Calls to `emit()` before this setter + * runs silently no-op (no tasks are active during daemon boot). + */ + setAnalyticsClient(client: IAnalyticsClient): void { + this.analyticsClient = client + } + + /** + * Cache per-task query execution metadata for later finalization. + * Symmetric to `QueryLogHandler.setQueryResult`. Called from the + * `QUERY_RESULT` transport handler fan-out in `brv-server.ts`. + */ + setQueryResult(taskId: string, metadata: QueryResultMetadata): void { + const state = this.tasks.get(taskId) + if (!state || state.flavor !== 'query') return + state.queryMeta = metadata + } + + private buildCurateRunPayload({ + identity, + outcome, + state, + task, + taskId, + }: { + identity: ProjectIdentity + outcome: 'cancelled' | 'completed' | 'error' | 'partial' + state: CurateTaskAnalyticsState + task: TaskInfo + taskId: string + }): CurateRunCompletedProps { + return { + duration_ms: this.durationMs(task), + operations_added: state.counters.added, + operations_deleted: state.counters.deleted, + operations_failed: state.counters.failed, + operations_merged: state.counters.merged, + operations_updated: state.counters.updated, + outcome, + pending_review_count: state.counters.pendingReview, + ...projectPathHashOptional(task.projectPath ?? state.projectPath), + ...(identity.spaceId === undefined ? {} : {space_id: identity.spaceId}), + task_id: taskId, + task_type: toAnalyticsTaskType(state.taskType), + ...(identity.teamId === undefined ? {} : {team_id: identity.teamId}), + } + } + + private async buildQueryCompletedPayload({ + identity, + outcome, + state, + task, + taskId, + }: { + identity: ProjectIdentity + outcome: 'cancelled' | 'completed' | 'error' + state: QueryTaskAnalyticsState + task: TaskInfo + taskId: string + }): Promise { + const readPaths = new Set() + let readToolCallCount = 0 + let searchCallCount = 0 + + for (const call of task.toolCalls ?? []) { + // `call.args` is a required `Record` on ToolCallEvent; + // index access returns `unknown` (possibly undefined when the key is + // absent), so the runtime `typeof === 'string'` check below is what + // actually narrows. No optional chain on `args` itself. + switch (call.toolName) { + case EXPAND_KNOWLEDGE_TOOL: { + readToolCallCount++ + const {overviewPath, stubPath} = call.args + if (typeof stubPath === 'string' && stubPath.length > 0) readPaths.add(stubPath) + if (typeof overviewPath === 'string' && overviewPath.length > 0) readPaths.add(overviewPath) + + break + } + + case READ_FILE_TOOL: { + readToolCallCount++ + const {filePath} = call.args + if (typeof filePath === 'string' && filePath.length > 0) readPaths.add(filePath) + + break + } + + case SEARCH_KNOWLEDGE_TOOL: { + searchCallCount++ + + break + } + // No default + } + } + + const cappedPaths = [...readPaths].sort().slice(0, MAX_READ_PATHS) + const tier = state.queryMeta?.tier + const matchedDocCount = state.queryMeta?.searchMetadata?.resultCount ?? 0 + + // M12.3: harvest per-path frontmatter on the same async read path used + // for curate emits. Entries whose file is unreadable / has no frontmatter + // carry empty keywords / tags / related_paths arrays — the wire shape + // is uniform regardless of read success. `Promise.all` preserves + // input-array order in the result regardless of which read settles first. + const readPathsWithMetadata = await Promise.all( + cappedPaths.map(async (p) => { + const fm = await this.readFrontmatterFields(p) + return { + keywords: fm.keywords ?? [], + // M14 review tightening: each related entry is structured so a + // later FU can populate the linked file's own keywords/tags + // without changing the wire shape. + related_paths: (fm.related ?? []).map((r) => ({ + keywords: [], + relative_path: r, + tags: [], + })), + relative_path: toRelativePath(p, task.projectPath), + tags: fm.tags ?? [], + } + }), + ) + + return { + cache_hit: tier === 0 || tier === 1, + duration_ms: this.durationMs(task), + matched_doc_count: matchedDocCount, + outcome, + ...projectPathHashOptional(task.projectPath), + read_doc_count: readPaths.size, + // M12.1 schema marks read_paths_with_metadata as optional outer array. + // Mirror that: omit the field when the command had no read paths + // (instead of emitting an empty array). Same idiom as `tier` above. + ...(readPathsWithMetadata.length > 0 ? {read_paths_with_metadata: readPathsWithMetadata} : {}), + read_tool_call_count: readToolCallCount, + search_call_count: searchCallCount, + ...(identity.spaceId === undefined ? {} : {space_id: identity.spaceId}), + task_id: taskId, + task_type: toAnalyticsTaskType(task.type), + ...(identity.teamId === undefined ? {} : {team_id: identity.teamId}), + ...(tier === undefined ? {} : {tier}), + } + } + + private async dispatchTerminal(taskId: string, task: TaskInfo, outcome: 'cancelled' | 'error'): Promise { + const state = this.tasks.get(taskId) + if (!state) return + + // Drain any in-flight per-op processing so CURATE_OPERATION_APPLIED + // emits land before this terminal emit. Symmetric to onTaskCompleted. + await this.pendingByTask.get(taskId) + + if (state.flavor === 'curate') { + const identity = await this.resolveIdentity(task.projectPath ?? state.projectPath) + this.emit( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + this.buildCurateRunPayload({identity, outcome, state, task, taskId}), + ) + } else { + const identity = await this.resolveIdentity(task.projectPath) + this.emit( + AnalyticsEventNames.QUERY_COMPLETED, + await this.buildQueryCompletedPayload({identity, outcome, state, task, taskId}), + ) + } + } + + private durationMs(task: TaskInfo): number { + return Math.max(0, (task.completedAt ?? Date.now()) - task.createdAt) + } + + private emit(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`AnalyticsHook: ${event} track failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * M15.8 — emit `mcp_tool_called` for MCP-originated tool-mode tasks. + * Gated on `clientType === 'mcp'` AND task type being a tool-mode flavor. + * Falls back to `'unknown'` when the MCP handshake had not delivered a + * client name by `handleTaskCreate` time (rare race; section 3 of M15.8 + * guarantees the name in steady-state). + */ + private emitMcpToolCalled(task: TaskInfo, success: boolean): void { + if (task.clientType !== 'mcp') return + const toolName = mcpToolNameForTaskType(task.type) + if (toolName === undefined) return + + this.emit(AnalyticsEventNames.MCP_TOOL_CALLED, { + client_name: task.clientName ?? 'unknown', + duration_ms: this.durationMs(task), + success, + tool_name: toolName, + }) + } + + /** + * M14.3 generic terminal-failure emit. Fired by both onTaskError and + * onTaskCancelled AFTER dispatchTerminal so M12 per-flavor failure + * emits land first on the wire. Cancellation maps to task_failed + * (not a distinct event) per the schema's docblock. + * + * M15.6: failure_kind is a coarse classifier passed by the caller — + * 'cancelled' from onTaskCancelled, classified-from-errorMessage from + * onTaskError (see classifyFailureKind). Raw error.message MUST NOT + * leak into the emit; only the canonical FailureKind tag does. + */ + private emitTaskFailed(taskId: string, task: TaskInfo, failureKind: FailureKind): void { + this.emit(AnalyticsEventNames.TASK_FAILED, { + duration_ms: this.durationMs(task), + failure_kind: failureKind, + ...projectPathHashOptional(task.projectPath), + task_id: taskId, + task_type: toAnalyticsTaskType(task.type), + }) + } + + private async processToolResult(taskId: string, payload: LlmToolResultEvent): Promise { + const state = this.tasks.get(taskId) + if (!state || state.flavor !== 'curate') return + + const ops = extractCurateOperations(payload) + for (const op of ops) { + if (op.status !== 'success') { + state.counters.failed++ + continue + } + + // Bump counters per op.type. UPSERT counts as `added` when the message + // hints at a new-file create (mirrors `computeSummary` in + // curate-log-handler.ts); otherwise treat as an update. + switch (op.type) { + case 'ADD': { + state.counters.added++ + break + } + + case 'DELETE': { + state.counters.deleted++ + break + } + + case 'MERGE': { + state.counters.merged++ + break + } + + case 'UPDATE': { + state.counters.updated++ + break + } + + case 'UPSERT': { + if (op.message?.includes('created new')) state.counters.added++ + else state.counters.updated++ + break + } + } + + if (op.needsReview === true) state.counters.pendingReview++ + + // `op.filePath` is optional on CurateLogOperation but every M12 emit + // requires absolute_path. Skip ops missing filePath so the daemon + // never emits a malformed row (these are rare; UPSERT/MERGE without + // a concrete file path would be the only realistic case). + if (!op.filePath) continue + + // M12.3: read post-op frontmatter for ADD / UPDATE / MERGE-target / + // UPSERT. DELETE skips the read (file is gone). Frontmatter fields + // default to empty arrays when the read fails (ENOENT, EACCES, + // malformed YAML) so the wire shape stays uniform. + // eslint-disable-next-line no-await-in-loop -- emit order MUST match op order + const frontmatter = op.type === 'DELETE' ? {} : await this.readFrontmatterFields(op.filePath) + + this.emit(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { + ...(op.confidence ? {confidence: op.confidence} : {}), + ...(op.impact ? {impact: op.impact} : {}), + keywords: frontmatter.keywords ?? [], + knowledge_path: op.path, + needs_review: op.needsReview ?? false, + operation_type: op.type, + ...projectPathHashOptional(state.projectPath), + ...(frontmatter.related ? {related: frontmatter.related} : {}), + relative_path: toRelativePath(op.filePath, state.projectPath), + tags: frontmatter.tags ?? [], + task_id: taskId, + }) + } + } + + /** + * Read the YAML frontmatter from `filePath` and return only `tags` / + * `keywords` / `related` arrays (capped at 50 entries / 256 chars per + * entry). Returns an empty object on ANY failure: ENOENT, EACCES, + * permission errors, malformed YAML. Telemetry MUST NOT crash the hook. + * + * Async (`node:fs/promises.readFile`) so the daemon event loop is free + * to serve other transport requests while the read is in flight. The + * per-task queue in `onToolResult` enforces emit-arrival order across + * concurrent invocations on the same task; for query-task termination + * `Promise.all` parallelises up to 10 reads while preserving array order. + * + * Short-circuits when analytics is disabled to avoid wasted disk I/O. + */ + private async readFrontmatterFields(filePath: string): Promise { + if (!this.isEnabled()) return {} + try { + const content = await this.readFile(filePath, 'utf8') + // M17 follow-up: HTML topic files (curate-tool-mode writes) carry the + // frontmatter as attributes on ``, NOT as YAML. parseFrontmatter + // returns null for them. Branch on extension so both formats produce + // the same FrontmatterFields shape downstream. + if (filePath.toLowerCase().endsWith('.html')) { + const htmlAttrs = readHtmlTopicSync(content).topicAttributes + return { + keywords: capStringArray(splitTopicAttrList(htmlAttrs.keywords)), + related: capStringArray(splitTopicAttrList(htmlAttrs.related)), + tags: capStringArray(splitTopicAttrList(htmlAttrs.tags)), + } + } + + const parsed = parseFrontmatter(content) + if (parsed === null) return {} + return { + keywords: capStringArray(parsed.frontmatter.keywords), + related: capStringArray(parsed.frontmatter.related), + tags: capStringArray(parsed.frontmatter.tags), + } + } catch { + // ENOENT, EACCES, permission, malformed YAML / HTML — all silently + // treated as "no frontmatter". No retry, no log noise. + return {} + } + } + + /** + * Resolve the project identity (spaceId + teamId) without ever throwing — + * a getIdentity rejection (config-load failure, projectStateLoader race, + * etc.) MUST NOT take down the terminal emit. Empty strings normalize to + * `undefined` per-field so the payload spread omits each independently. + * + * Short-circuits on `!isEnabled()` so the daemon doesn't touch the + * project-state loader on every task termination when analytics is off. + * Mirrors the `readFrontmatterFields` precedent. + */ + private async resolveIdentity(projectPath: string | undefined): Promise { + if (!this.isEnabled()) return {} + try { + const raw = await this.getIdentity(projectPath) + return { + spaceId: typeof raw.spaceId === 'string' && raw.spaceId.length > 0 ? raw.spaceId : undefined, + teamId: typeof raw.teamId === 'string' && raw.teamId.length > 0 ? raw.teamId : undefined, + } + } catch (error) { + processLog( + `AnalyticsHook: getIdentity failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return {} + } + } +} + +/** + * Split a `` attribute value into a string array. The HTML writer + * emits these as comma-separated lists (e.g. `tags="analytics, m17, tool-mode"`) + * to mirror the YAML array semantics. Whitespace around each entry is + * trimmed; empty entries are dropped so a trailing comma never produces + * a zero-length tag. + * + * PR #728 review fix: HTML `related` refs carry a leading `@` marker (e.g. + * `related="@analytics/x.html, @analytics/y.html"`) per the renderer + * convention. The legacy YAML path stores them stripped — see + * `related-ref-warner.ts:33`. Canonicalize here so the same wire field + * (`curate_operation_applied.related` / + * `query_completed.read_paths_with_metadata[].related_paths[].relative_path`) + * doesn't carry two shapes across HTML and YAML sources. + */ +function splitTopicAttrList(value: string | undefined): string[] | undefined { + if (typeof value !== 'string' || value.trim().length === 0) return undefined + const parts = value + .split(',') + .map((part) => part.trim()) + .map((part) => (part.startsWith('@') ? part.slice(1) : part)) + .filter((part) => part.length > 0) + return parts.length > 0 ? parts : undefined +} diff --git a/src/server/infra/process/curate-log-handler.ts b/src/server/infra/process/curate-log-handler.ts index bd29ad1fd..c3b919fa5 100644 --- a/src/server/infra/process/curate-log-handler.ts +++ b/src/server/infra/process/curate-log-handler.ts @@ -50,7 +50,12 @@ function telemetryFields(record: CurateUsageRecord | undefined): { } } -const CURATE_TASK_TYPES = ['curate', 'curate-folder'] as const +// `curate-html-direct` is the pre-ENG-2925 name still dispatched by the +// daemon; `curate-tool-mode` is the post-rename name. Both are listed +// so M12 state init in AnalyticsHook kicks in for tool-mode curates. +// The analytics wire canonicalizes both to `curate-tool-mode` via +// `toAnalyticsTaskType` in `analytics-hook.ts`. +export const CURATE_TASK_TYPES = ['curate', 'curate-folder', 'curate-html-direct', 'curate-tool-mode'] as const // ── Summary computation ─────────────────────────────────────────────────────── @@ -224,6 +229,16 @@ export class CurateLogHandler implements ITaskLifecycleHook { async onTaskCreate(task: TaskInfo): Promise { if (!CURATE_TASK_TYPES.includes(task.type as (typeof CURATE_TASK_TYPES)[number])) return if (!task.projectPath) return + // PR #728 review fix: `curate-tool-mode` writes are persisted directly + // by `agent-process.ts` via `buildCurateHtmlLogEntry` + `FileCurateLogStore.save`. + // Before M17, `CurateLogHandler.onTaskCompleted` also saved a sibling entry but + // with `operations: []` (because `onToolResult` never fired for tool-mode) + // — tolerably useless, ignored by `brv curate view`. After M17.1 the + // synthetic `llmservice:toolResult` makes `onToolResult` accumulate ops + // here too, so the sibling entry becomes a near-duplicate of the + // agent-process one. Skip the create path for tool-mode entirely; the + // handler stays the source of truth for legacy `curate` / `curate-folder`. + if (task.type === 'curate-tool-mode') return const store = this.getOrCreateStore(task.projectPath) const logId = await store.getNextId().catch(() => {}) @@ -289,7 +304,7 @@ export class CurateLogHandler implements ITaskLifecycleHook { }) } - onToolResult(taskId: string, payload: LlmToolResultEvent): void { + async onToolResult(taskId: string, payload: LlmToolResultEvent): Promise { const state = this.tasks.get(taskId) if (!state) return diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 0d3183ef0..92a7a4e86 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -8,6 +8,7 @@ import {access} from 'node:fs/promises' import {join} from 'node:path' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IConnectorManager} from '../../core/interfaces/connectors/i-connector-manager.js' import type {IProviderConfigStore} from '../../core/interfaces/i-provider-config-store.js' import type {IProviderKeychainStore} from '../../core/interfaces/i-provider-keychain-store.js' @@ -17,6 +18,8 @@ import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-sto import type {IBillingConfigStore} from '../../core/interfaces/storage/i-billing-config-store.js' import type {ISettingsStore} from '../../core/interfaces/storage/i-settings-store.js' import type {ITransportServer} from '../../core/interfaces/transport/i-transport-server.js' +import type {AnalyticsFlushScheduler} from '../analytics/analytics-flush-scheduler.js' +import type {ClientManager} from '../client/client-manager.js' import type {ProjectBroadcaster, ProjectPathResolver} from '../transport/handlers/handler-types.js' import {ReviewEvents} from '../../../shared/transport/events/review-events.js' @@ -24,7 +27,16 @@ import {getAuthConfig} from '../../config/auth.config.js' import {getCurrentConfig} from '../../config/environment.js' import {API_V1_PATH, BRV_DIR} from '../../constants.js' import {TransportStateEventNames} from '../../core/domain/transport/schemas.js' +import {getGlobalDataDir} from '../../utils/global-data-path.js' import {getProjectDataDir} from '../../utils/path-utils.js' +import {readCliVersion} from '../../utils/read-cli-version.js' +import {AnalyticsBackoffPolicy} from '../analytics/analytics-backoff-policy.js' +import {AnalyticsClient} from '../analytics/analytics-client.js' +import {BoundedQueue} from '../analytics/bounded-queue.js' +import {buildAnalyticsStatusSnapshot} from '../analytics/build-status-snapshot.js' +import {IdentityResolver} from '../analytics/identity-resolver.js' +import {JsonlAnalyticsStore} from '../analytics/jsonl-analytics-store.js' +import {SuperPropertiesResolver} from '../analytics/super-properties-resolver.js' import {OAuthService} from '../auth/oauth-service.js' import {OidcDiscoveryService} from '../auth/oidc-discovery-service.js' import {HttpBillingService} from '../billing/http-billing-service.js' @@ -49,16 +61,21 @@ import {createHubKeychainStore} from '../hub/hub-keychain-store.js' import {HubRegistryConfigStore} from '../hub/hub-registry-config-store.js' import {HttpSpaceService} from '../space/http-space-service.js' import {FileCurateLogStore} from '../storage/file-curate-log-store.js' +import {FileGlobalConfigStore} from '../storage/file-global-config-store.js' import {FileReviewBackupStore} from '../storage/file-review-backup-store.js' import {createTokenStore} from '../storage/token-store.js' import {HttpTeamService} from '../team/http-team-service.js' import {FsTemplateLoader} from '../template/fs-template-loader.js' import { + AnalyticsHandler, + AnalyticsListHandler, + AnalyticsStatusHandler, AuthHandler, BillingHandler, ConfigHandler, ConnectorsHandler, ContextTreeHandler, + GlobalConfigHandler, HubHandler, InitHandler, LocationsHandler, @@ -73,17 +90,29 @@ import { SourceHandler, SpaceHandler, StatusHandler, + SwarmHandler, TeamHandler, VcHandler, WorktreeHandler, } from '../transport/handlers/index.js' import {HttpUserService} from '../user/http-user-service.js' import {FileVcGitConfigStore} from '../vc/file-vc-git-config-store.js' +import {wireAnalyticsAuthPreTransition} from './wire-analytics-auth-pre-transition.js' +import {wireAnalyticsAuthTransition} from './wire-analytics-auth-transition.js' +import {wireAnalyticsFlushScheduler} from './wire-analytics-flush-scheduler.js' +import {wireAnalyticsHttpSender} from './wire-analytics-http-sender.js' export interface FeatureHandlersOptions { authStateStore: IAuthStateStore billingConfigStoreFactory: (projectPath: string) => IBillingConfigStore broadcastToProject: ProjectBroadcaster + /** + * M15.5: optional ClientManager. When provided, setupFeatureHandlers + * wires the analyticsClient into it so WebUI session lifecycle events + * (`webui_session_started` / `webui_session_ended`) can fire from + * register/unregister. + */ + clientManager?: ClientManager getActiveProjectPaths: () => string[] log: (msg: string) => void projectRegistry: IProjectRegistry @@ -96,6 +125,31 @@ export interface FeatureHandlersOptions { webuiPort?: number } +/** + * Result of setting up feature handlers. The daemon-scoped analytics + * client is returned so the caller (brv-server.ts) can fire `daemon_start` + * AFTER auth state is loaded — emitting it inside this function would + * stamp the event with anonymous identity even for logged-in users, + * because authStateStore.loadToken() runs after setupFeatureHandlers. + */ +export interface SetupFeatureHandlersResult { + readonly analyticsClient: IAnalyticsClient + /** + * M4.3: scheduler that owns the 30s interval + 20-event threshold + * triggers. The composition root (`brv-server.ts`) starts it after + * auth state has loaded so the first tick has a real identity, and + * stops it during shutdown before invoking `flushFinal()` so no new + * ticks fire mid-shutdown. + */ + readonly analyticsFlushScheduler: AnalyticsFlushScheduler + /** + * Returns the daemon's cached analytics-enabled flag. M12.3 consumers + * (e.g. AnalyticsHook) use this to short-circuit disk I/O when analytics + * is off — complements `AnalyticsClient.track` no-op gate. + */ + readonly isAnalyticsEnabled: () => boolean +} + /** * Setup all feature handlers on the transport server. * These handlers implement the TUI ↔ Server event contract (auth:*, config:*, status:*, etc.). @@ -104,6 +158,7 @@ export async function setupFeatureHandlers({ authStateStore, billingConfigStoreFactory, broadcastToProject, + clientManager, getActiveProjectPaths, log, projectRegistry, @@ -114,7 +169,7 @@ export async function setupFeatureHandlers({ settingsStore, transport, webuiPort, -}: FeatureHandlersOptions): Promise { +}: FeatureHandlersOptions): Promise { const envConfig = getCurrentConfig() const tokenStore = createTokenStore() const projectConfigStore = new ProjectConfigStore() @@ -134,13 +189,161 @@ export async function setupFeatureHandlers({ // Global handlers (no project context needed) new ConfigHandler({transport}).setup() - new SettingsHandler({store: settingsStore, transport}).setup() + // SettingsHandler is constructed below, after analyticsClient is built, + // so it can receive the optional analyticsClient dep for M15.4 emits. + + // GlobalConfig: handler retains a sync-cached `analytics` flag so M2.5's + // AnalyticsClient.isEnabled can be a sync getter (file reads are async). + // refreshCache() must complete BEFORE AnalyticsClient is constructed so + // the very first track() call (daemon_start) sees the correct flag. + const globalConfigStore = new FileGlobalConfigStore() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + // M2.5: assemble the daemon-scoped analytics client. Construction happens + // here because the resolvers and queue share the same `globalConfigStore` + // instance already in scope. The `daemon_start` event is NOT fired here — + // it is fired by the caller (brv-server.ts) after authStateStore.loadToken() + // resolves so the event reflects the real identity instead of anonymous. + // + // M9.3: a single JsonlAnalyticsStore instance is constructed here and + // injected into the AnalyticsClient. The same instance will be shared with + // M11.2's analytics-list-handler when it lands so both read/write the same + // file. Storage path: `/analytics-queue.jsonl`. + const jsonlAnalyticsStore = new JsonlAnalyticsStore({baseDir: getGlobalDataDir()}) + // M4.2: real HTTP sender. See `wireAnalyticsHttpSender` for the + // axios + sender composition. Headers are computed per send so + // device-id and session-id reflect the current GlobalConfig + + // AuthStateStore state at flush time. The per-event identity inside + // each record (M4.1) remains authoritative for the wire body; the + // request-level session header is a backwards-compat hint only. + const analyticsSender = wireAnalyticsHttpSender({ + analyticsBaseUrl: envConfig.analyticsBaseUrl, + authStateReader: authStateStore, + globalConfigStore, + version: readCliVersion(), + }) + // M4.3: scheduler is built AFTER the client but needs to be referenced + // by it (`onAfterTrack: () => scheduler.notifyPushed()`). Resolve the + // cycle with a mutable holder: the client closure reads the latest + // assigned value at call-time, so the scheduler is in place by the + // time the first track lands. Queue is hoisted to a shared instance so + // both client (push) and scheduler (queueSize) observe the same state. + const analyticsQueue = new BoundedQueue() + // M4.5: backoff policy shared between the client (mutates via + // onSuccess/onFailure inside runFlush) and the scheduler (reads + // nextDelayMs at each arm). Pure in-memory state, no persistence — + // a daemon restart starts from the base 30s interval. + const analyticsBackoffPolicy = new AnalyticsBackoffPolicy() + // Holder for the scheduler reference shared with `onAfterTrack`. Using + // a plain object instead of `let` so the lint rule sees a const binding + // (the closure reads `.value` on every call). The scheduler instance is + // assigned immediately after AnalyticsClient construction below. + const schedulerHolder: {value: AnalyticsFlushScheduler | undefined} = {value: undefined} + const analyticsClient: IAnalyticsClient = new AnalyticsClient({ + backoffPolicy: analyticsBackoffPolicy, + identityResolver: new IdentityResolver(authStateStore, globalConfigStore), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: jsonlAnalyticsStore, + onAfterTrack() { + schedulerHolder.value?.notifyPushed() + }, + queue: analyticsQueue, + sender: analyticsSender, + superPropsResolver: new SuperPropertiesResolver(globalConfigStore), + }) + + const analyticsFlushScheduler = wireAnalyticsFlushScheduler({ + analyticsClient, + backoffPolicy: analyticsBackoffPolicy, + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: jsonlAnalyticsStore, + queue: analyticsQueue, + }) + schedulerHolder.value = analyticsFlushScheduler + + // M4.1: subscribe the analytics client to identity-changing auth + // transitions. See `wireAnalyticsAuthTransition` for the + // login/logout/refresh decision logic. + wireAnalyticsAuthTransition(authStateStore, analyticsClient) + + // M4.4: subscribe the pre-transition hook so the client flushes + // surviving events under the OLD session header BEFORE the new + // token replaces the cache. Paired with the M4.1 post-hook above + // (drops anything the flush couldn't deliver in time). + wireAnalyticsAuthPreTransition(authStateStore, analyticsClient) + + // M4.4: close the global-config-handler ↔ analyticsClient cycle. + // The handler was constructed earlier (so its sync cache was + // populated before the client read it); now that the client + // exists, register it so `brv settings set analytics.share false` can call + // `abort()` to cancel any in-flight HTTP. + globalConfigHandler.setAnalyticsClient(analyticsClient) + + // M15.5: hook the analytics client into ClientManager so register/unregister + // can fire webui_session_started / webui_session_ended for browser sessions. + clientManager?.setAnalyticsClient(analyticsClient) + + // M2.6: route incoming analytics:track events from non-forked clients + // (TUI, oclif, MCP, webui) to the same singleton. + new AnalyticsHandler({analyticsClient, transport}).setup() + + // Global SettingsHandler (no project context). Deferred from line 180 so + // analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset` + // emits. M16.3 wires the `analytics.status` readonly-info provider so + // `brv settings get analytics.status` returns the same operational + // snapshot as the legacy `brv analytics status` (now `brv settings get analytics.status`). + const analyticsStatusSnapshotDeps = { + analyticsClient, + backoffPolicy: analyticsBackoffPolicy, + endpoint: envConfig.analyticsBaseUrl ?? '', + isAnalyticsEnabled: () => globalConfigHandler.getCachedAnalytics(), + } + new SettingsHandler({ + analyticsClient, + // Route `analytics.share` GET/SET/RESET/LIST through the + // global-config handler so the canonical storage in config.json, the + // device-id seeding race fix, the analytics cache, and the + // abort-on-disable side-effect all stay unchanged. + globalConfigHandler, + infoProviders: new Map([ + ['analytics.status', async () => buildAnalyticsStatusSnapshot(analyticsStatusSnapshotDeps)], + ]), + store: settingsStore, + transport, + }).setup() + + // M11.2: webui-facing read API. Shares the same JsonlAnalyticsStore instance + // as the AnalyticsClient so reads see exactly what trackAsync persisted. + new AnalyticsListHandler({jsonlStore: jsonlAnalyticsStore, transport}).setup() + + // M4.6: `brv settings get analytics.status` read API. Composes runtime state + // (client) + backoff state (policy) + enabled flag (config) + endpoint + // (env) into one wire response. Endpoint is `envConfig.analyticsBaseUrl` + // or empty string; the handler substitutes "(not configured)" for the + // empty case and forces backoff.state to 'unreachable'. + new AnalyticsStatusHandler({ + analyticsClient, + backoffPolicy: analyticsBackoffPolicy, + endpoint: envConfig.analyticsBaseUrl ?? '', + isAnalyticsEnabled: () => globalConfigHandler.getCachedAnalytics(), + transport, + }).setup() new AuthHandler({ + // Thread the analytics client so the auth handler can emit + // auth_login / auth_logout on identity transitions. The Mixpanel + // forwarder's alias() path keys off the auth_login event. + analyticsClient, authService: new OAuthService(authConfig), authStateStore, browserLauncher: new SystemBrowserLauncher(), callbackHandler: new CallbackHandler(), + // The handler doubles as the device_id rotator (it owns the same + // writeChain that serializes analytics-flag toggles, so rotation + // cannot race a concurrent enable/disable into a stale config). + globalConfigRotator: globalConfigHandler, projectConfigStore, providerConfigStore, resolveProjectPath, @@ -267,9 +470,10 @@ export async function setupFeatureHandlers({ transport, }).setup() - new MigrateHandler({resolveProjectPath, transport}).setup() + new MigrateHandler({analyticsClient, resolveProjectPath, transport}).setup() new ResetHandler({ + analyticsClient, contextTreeService, contextTreeSnapshotService, curateLogStoreFactory: (projectPath) => new FileCurateLogStore({baseDir: getProjectDataDir(projectPath)}), @@ -279,6 +483,7 @@ export async function setupFeatureHandlers({ }).setup() new ReviewHandler({ + analyticsClient, curateLogStoreFactory: (projectPath) => new FileCurateLogStore({baseDir: getProjectDataDir(projectPath)}), onResolved({projectPath, taskId}) { broadcastToProject(projectPath, ReviewEvents.NOTIFY, {pendingCount: 0, taskId}) @@ -290,6 +495,7 @@ export async function setupFeatureHandlers({ }).setup() new SpaceHandler({ + analyticsClient, broadcastToProject, cogitPullService, contextTreeMerger, @@ -305,6 +511,7 @@ export async function setupFeatureHandlers({ }).setup() new ConnectorsHandler({ + analyticsClient, connectorManagerFactory, resolveProjectPath, transport, @@ -316,6 +523,7 @@ export async function setupFeatureHandlers({ const hubKeychainStore = createHubKeychainStore() await new HubHandler({ + analyticsClient, hubInstallService, hubKeychainStore, hubRegistryConfigStore, @@ -325,6 +533,7 @@ export async function setupFeatureHandlers({ }).setup() new InitHandler({ + analyticsClient, broadcastToProject, cogitPullService, connectorManagerFactory, @@ -340,6 +549,7 @@ export async function setupFeatureHandlers({ }).setup() new VcHandler({ + analyticsClient, broadcastToProject, contextTreeService, gitRemoteBaseUrl: envConfig.gitRemoteBaseUrl, @@ -355,6 +565,7 @@ export async function setupFeatureHandlers({ }).setup() new ContextTreeHandler({ + analyticsClient, contextFileReader, contextTreeService, gitService, @@ -363,8 +574,24 @@ export async function setupFeatureHandlers({ }).setup() // Worktree & source handlers - new WorktreeHandler({resolveProjectPath, transport}).setup() - new SourceHandler({resolveProjectPath, transport}).setup() + new WorktreeHandler({analyticsClient, resolveProjectPath, transport}).setup() + new SourceHandler({analyticsClient, resolveProjectPath, transport}).setup() + + // Swarm handler — thin emit surface for federated memory-provider events + // (M16.9 / M16.10 / M16.11). The CLI swarm commands and LLM swarm_* tools + // run their coordinator client-side and dispatch terminal-state events + // through this handler. See `swarm-handler.ts` docblock for the forward + // direction (moving the coordinator into the daemon process). + new SwarmHandler({analyticsClient, transport}).setup() log('Feature handlers registered') + + // M12.3: expose the cached-analytics check so daemon-side consumers + // (e.g. AnalyticsHook) can short-circuit disk I/O when analytics is off. + // Same callback shape used internally by AnalyticsClient at line 171. + return { + analyticsClient, + analyticsFlushScheduler, + isAnalyticsEnabled: (): boolean => globalConfigHandler.getCachedAnalytics(), + } } diff --git a/src/server/infra/process/query-log-handler.ts b/src/server/infra/process/query-log-handler.ts index a9b0cb114..16a561774 100644 --- a/src/server/infra/process/query-log-handler.ts +++ b/src/server/infra/process/query-log-handler.ts @@ -34,7 +34,7 @@ function telemetryFields(result: QueryResultMetadata | undefined): { } /** Query metadata without the response string (response arrives via task:completed). */ -type QueryResultMetadata = Omit +export type QueryResultMetadata = Omit type TaskState = { /** Cached initial entry — used in onTaskCompleted/onTaskError to avoid a getById round-trip. */ @@ -44,7 +44,10 @@ type TaskState = { queryResult?: QueryResultMetadata } -const QUERY_TASK_TYPES: ReadonlySet = new Set(['query']) +// `query-tool-mode` is the v4.0 daemon dispatch name; legacy `query` is +// kept for back-compat. Both names enable M12 state init in AnalyticsHook +// (and matching query-log persistence here). +export const QUERY_TASK_TYPES: ReadonlySet = new Set(['query', 'query-tool-mode']) // ── QueryLogHandler ────────────────────────────────────────────────────────── diff --git a/src/server/infra/process/synthetic-tool-result-emit.ts b/src/server/infra/process/synthetic-tool-result-emit.ts new file mode 100644 index 000000000..1861fdc88 --- /dev/null +++ b/src/server/infra/process/synthetic-tool-result-emit.ts @@ -0,0 +1,231 @@ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {randomUUID} from 'node:crypto' +import {join} from 'node:path' + +import type {CurateLogOperation} from '../../core/domain/entities/curate-log-entry.js' +import type { + QueryToolModeMatchedDoc, + QueryToolModeMetadata, +} from '../../core/interfaces/executor/i-query-executor.js' + +import {BRV_DIR, CONTEXT_TREE_DIR} from '../../constants.js' +import {LlmEventNames} from '../../core/domain/transport/schemas.js' + +/** + * Tool-mode synthetic LLM-event emitters. + * + * Tool-mode dispatch (`curate-tool-mode`, `query-tool-mode`) bypasses the + * `llmservice:toolResult` / `llmservice:toolCall` channel that the legacy + * LLM-driven path used. AnalyticsHook, CurateLogHandler, and QueryLogHandler + * all listen on that channel — when it is silent, every downstream M12 emit + * fires with zero inputs (e.g. `curate_run_completed{operations_added:0}`, + * `query_completed{matched_doc_count:0, read_paths_with_metadata: absent}`). + * + * These helpers shape the same wire envelopes the legacy LLM path produced + * and ship them through the existing `ITransportClient`, so the daemon's + * `TaskRouter.routeLlmEvent` chain runs unchanged. No producer code needs + * to learn about tool-mode. + * + * Errors are swallowed — analytics MUST NOT block the user-facing + * curate/query response. + */ + +/** Synthetic events have no LLM session; use empty-string for the field. */ +const SYNTHETIC_SESSION_ID = '' + +/** + * Fire-and-forget emit that swallows BOTH synchronous throws and async + * rejections (PR #728 review fix). `ITransportClient.request` can return + * a Promise that rejects after the synchronous call returns; without this + * wrapper that rejection becomes an unhandled-rejection warning in Node 16+ + * and a crash under strict modes. The unit tests only exercise sync throws, + * so this guards the prod path against the async case that test stubs miss. + */ +function safeDispatch( + transport: ITransportClient, + event: string, + payload: Record, + log: ((msg: string) => void) | undefined, + context: string, +): void { + // A logging sink that itself throws must never escalate into the telemetry + // path: on the sync path it would escape this function, and inside the + // async `.catch` it would become a fresh unhandled rejection — the very + // failure mode this wrapper exists to prevent. Swallow any error from the + // sink itself. + const safeLog = (msg: string): void => { + try { + log?.(msg) + } catch { + /* logging is best-effort — never let the sink crash the daemon */ + } + } + + try { + const result = transport.request(event, payload) as unknown + if (result && typeof (result as PromiseLike).then === 'function') { + ;(result as Promise).catch((error: unknown) => { + safeLog(`${context}: async rejection — ${error instanceof Error ? error.message : String(error)}`) + }) + } + } catch (error) { + safeLog(`${context}: sync throw — ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** + * Marker stamped on every synthetic event's `metadata`. `TaskRouter.routeLlmEvent` + * inspects this and SKIPS the per-client `sendTo()` + `broadcastToProjectRoom()` + * for synthetic events. Without the skip, the synthetic tool-call envelopes + * leak into the CLI's streamed JSON output, the TUI live view, every MCP + * client subscribed to the project, and the webui — surfacing internal + * analytics plumbing as user-facing progress events. + * + * The internal accumulator + `onToolResult` hook chain still run (they're + * gated separately in `routeLlmEvent`), so AnalyticsHook / CurateLogHandler / + * QueryLogHandler get their inputs unchanged. + */ +export const SYNTHETIC_EVENT_METADATA = {_synthetic: true} as const + +/** + * Fire a synthetic `llmservice:toolResult` mirroring the legacy curate-tool + * envelope (`{applied: CurateLogOperation[]}`). + * + * Consumed by: + * - `AnalyticsHook.processToolResult` → `extractCurateOperations` → + * `curate_operation_applied` per op + bumps `curate_run_completed.operations_*` + * - `CurateLogHandler.onToolResult` → persistence to `curate-log.jsonl` + * (parallel coverage — the same gap exists there) + */ +export function emitSyntheticCurateToolResult(opts: { + log?: (msg: string) => void + operations: readonly CurateLogOperation[] + taskId: string + transport: ITransportClient +}): void { + const {log, operations, taskId, transport} = opts + if (operations.length === 0) return + safeDispatch( + transport, + LlmEventNames.TOOL_RESULT, + { + metadata: SYNTHETIC_EVENT_METADATA, + result: JSON.stringify({applied: operations}), + sessionId: SYNTHETIC_SESSION_ID, + success: true, + taskId, + toolName: 'curate', + }, + log, + `synthetic curate toolResult emit failed for ${taskId}`, + ) +} + +/** + * Fire synthetic `llmservice:toolCall` events for the deterministic BM25 + * retrieval + per-doc render that `brv query` runs server-side. + * + * Consumed by: + * - `AnalyticsHook.buildQueryCompletedPayload` reads `task.toolCalls`: + * - `search_knowledge` calls bump `search_call_count` + * - `read_file` calls bump `read_tool_call_count` AND seed + * `read_paths_with_metadata[]` (enriched from each file's frontmatter) + * + * `matchedDocs[i].path` is a context-tree-relative path (e.g. + * `development/guidelines/x.md`). The enrichment reader needs an absolute + * path to find the file on disk; we join against `/.brv/context-tree/` + * before passing it through `args.filePath`. `AnalyticsHook.toRelativePath` + * then translates the absolute path back to project-relative for the wire + * `relative_path` field. + * + * PRIVACY: the raw user query string is NOT included in `args`. Only + * structured retrieval metadata (tier, count, score, cacheHit) flows. + */ +export function emitSyntheticQueryToolCalls(opts: { + log?: (msg: string) => void + matchedDocs: readonly QueryToolModeMatchedDoc[] + metadata: QueryToolModeMetadata + projectPath: string + taskId: string + transport: ITransportClient +}): void { + const {log, matchedDocs, metadata, projectPath, taskId, transport} = opts + const contextTreeRoot = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + + // PR #728 review fix: emit each toolCall + a paired toolResult. The + // accumulator's `TOOL_RESULT` branch (`task-router.ts` TOOL_RESULT case) + // matches on `callId` and flips the running call to `completed`. Without + // the pair, the accumulator persists `status: 'running'` forever and + // task-history snapshots show the synthetic call stuck mid-flight. + // Sharing the callId between the call and its result is what links them. + const searchCallId = randomUUID() + safeDispatch( + transport, + LlmEventNames.TOOL_CALL, + { + args: { + cacheHit: metadata.cacheHit ?? null, + matchedCount: matchedDocs.length, + tier: metadata.tier, + topScore: metadata.topScore, + totalFound: metadata.totalFound, + }, + callId: searchCallId, + metadata: SYNTHETIC_EVENT_METADATA, + sessionId: SYNTHETIC_SESSION_ID, + taskId, + toolName: 'search_knowledge', + }, + log, + `synthetic query search_knowledge toolCall emit failed for ${taskId}`, + ) + safeDispatch( + transport, + LlmEventNames.TOOL_RESULT, + { + callId: searchCallId, + metadata: SYNTHETIC_EVENT_METADATA, + result: JSON.stringify({matched: matchedDocs.length, tier: metadata.tier}), + sessionId: SYNTHETIC_SESSION_ID, + success: true, + taskId, + toolName: 'search_knowledge', + }, + log, + `synthetic query search_knowledge toolResult emit failed for ${taskId}`, + ) + + for (const doc of matchedDocs) { + const readCallId = randomUUID() + safeDispatch( + transport, + LlmEventNames.TOOL_CALL, + { + args: {filePath: join(contextTreeRoot, doc.path)}, + callId: readCallId, + metadata: SYNTHETIC_EVENT_METADATA, + sessionId: SYNTHETIC_SESSION_ID, + taskId, + toolName: 'read_file', + }, + log, + `synthetic query read_file toolCall emit failed for ${taskId}`, + ) + safeDispatch( + transport, + LlmEventNames.TOOL_RESULT, + { + callId: readCallId, + metadata: SYNTHETIC_EVENT_METADATA, + result: JSON.stringify({path: doc.path}), + sessionId: SYNTHETIC_SESSION_ID, + success: true, + taskId, + toolName: 'read_file', + }, + log, + `synthetic query read_file toolResult emit failed for ${taskId}`, + ) + } +} diff --git a/src/server/infra/process/task-history-entry-builder.ts b/src/server/infra/process/task-history-entry-builder.ts index 6cb10b1e9..f95548dad 100644 --- a/src/server/infra/process/task-history-entry-builder.ts +++ b/src/server/infra/process/task-history-entry-builder.ts @@ -44,7 +44,15 @@ function baseFromTaskInfo(task: TaskInfo): Record { ...(task.reasoningContents === undefined ? {} : {reasoningContents: task.reasoningContents}), ...(task.responseContent === undefined ? {} : {responseContent: task.responseContent}), ...(task.sessionId === undefined ? {} : {sessionId: task.sessionId}), - ...(task.toolCalls === undefined ? {} : {toolCalls: task.toolCalls}), + // PR #728 review fix (M17): tool-mode dispatch forges synthetic + // `llmservice:toolCall` envelopes so AnalyticsHook can read them off + // task.toolCalls — but those entries MUST NOT surface in persisted + // history or the WebUI task-detail panel as if they were real LLM tool + // calls. The accumulator stamps them with `_synthetic: true`; strip + // here so history sees only the LLM-driven calls. + ...(task.toolCalls === undefined + ? {} + : {toolCalls: task.toolCalls.filter((c) => c._synthetic !== true)}), ...(task.worktreeRoot === undefined ? {} : {worktreeRoot: task.worktreeRoot}), } } diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 46f251904..2d649744d 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -16,6 +16,7 @@ */ import type {ReasoningContentItem, ToolCallEvent} from '../../../shared/transport/events/task-events.js' +import type {ClientType} from '../../core/domain/client/client-info.js' import type { LlmChunkEvent, LlmErrorEvent, @@ -146,6 +147,13 @@ type TaskRouterOptions = { * Failures are swallowed (fail-open) so dispatch is never blocked. */ resolveActiveProvider?: () => Promise<{model?: string; provider?: string}> + /** + * M15.8: snapshot the submitting client's identity (transport type + + * IDE name) at task-create. Resolved here because the client may + * disconnect mid-task, leaving ClientManager.get() unable to recover + * the values by the time AnalyticsHook fires the terminal emit. + */ + resolveClientIdentity?: (clientId: string) => undefined | {clientName?: string; clientType?: ClientType} /** Resolves the projectPath a client registered with (from client:register). */ resolveClientProjectPath?: (clientId: string) => string | undefined transport: ITransportServer @@ -160,6 +168,18 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +/** + * M17: tool-mode curate / query emit synthetic `llmservice:toolResult` / + * `llmservice:toolCall` events so the lifecycle-hook chain (AnalyticsHook, + * CurateLogHandler, QueryLogHandler) has inputs. Those events MUST NOT + * broadcast to clients (CLI, TUI, MCP, webui) — they're internal plumbing, + * not user-visible progress. The marker lives under `metadata._synthetic` + * (declared in synthetic-tool-result-emit.ts:SYNTHETIC_EVENT_METADATA). + */ +function isSyntheticLlmEvent(data: {[key: string]: unknown}): boolean { + return isRecord(data.metadata) && data.metadata._synthetic === true +} + /** * Bounded-concurrency map for async I/O (M2.16 pass-2 lazy crack of data files). * Keeps file-descriptor usage well under macOS default soft limit (256). @@ -319,6 +339,7 @@ export class TaskRouter { private readonly projectRegistry: IProjectRegistry | undefined private readonly projectRouter: IProjectRouter | undefined private readonly resolveActiveProvider: TaskRouterOptions['resolveActiveProvider'] + private readonly resolveClientIdentity: TaskRouterOptions['resolveClientIdentity'] private readonly resolveClientProjectPath: ((clientId: string) => string | undefined) | undefined /** Track active tasks */ private tasks: Map = new Map() @@ -336,6 +357,7 @@ export class TaskRouter { this.projectRegistry = options.projectRegistry this.projectRouter = options.projectRouter this.resolveActiveProvider = options.resolveActiveProvider + this.resolveClientIdentity = options.resolveClientIdentity this.resolveClientProjectPath = options.resolveClientProjectPath } @@ -548,6 +570,11 @@ export class TaskRouter { const callId = typeof data.callId === 'string' ? data.callId : undefined const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '' const toolName = typeof data.toolName === 'string' ? data.toolName : '' + // PR #728 review fix: forward the M17 `_synthetic` marker from the + // wire envelope's `metadata` onto the persisted ToolCallEvent so + // downstream consumers (TaskHistoryHook, WebUI task-detail panel) + // can hide synthetic accumulator entries as internal plumbing. + const isSynthetic = isSyntheticLlmEvent(data) const newCall: ToolCallEvent = { args, ...(callId === undefined ? {} : {callId}), @@ -555,6 +582,7 @@ export class TaskRouter { status: 'running', timestamp: Date.now(), toolName, + ...(isSynthetic ? {_synthetic: true as const} : {}), } this.tasks.set(taskId, { ...task, @@ -977,12 +1005,19 @@ export class TaskRouter { // awaiting the handler. const {model, provider} = this.resolveActiveProvider ? await this.safeResolveActiveProvider() : {} + // M15.8: snapshot the submitter's identity so AnalyticsHook can emit + // mcp_tool_called for tool-mode tasks even if the MCP client disconnects + // between handleTaskCreate and the terminal task event. + const identity = this.resolveClientIdentity?.(clientId) + this.tasks.set(taskId, { clientId, content: data.content, createdAt: Date.now(), status: 'created', ...(data.clientCwd ? {clientCwd: data.clientCwd} : {}), + ...(identity?.clientName ? {clientName: identity.clientName} : {}), + ...(identity?.clientType ? {clientType: identity.clientType} : {}), ...(data.files?.length ? {files: data.files} : {}), ...(data.folderPath ? {folderPath: data.folderPath} : {}), ...(model ? {model} : {}), @@ -1745,9 +1780,12 @@ export class TaskRouter { } private registerLlmEvent(eventName: E): void { - this.transport.onRequest(eventName, (data) => { + this.transport.onRequest(eventName, async (data) => { if (!hasTaskId(data)) return - this.routeLlmEvent(eventName, data) + // `routeLlmEvent` is async because TOOL_RESULT hooks (AnalyticsHook) + // do disk I/O. socket-io-transport-server awaits this handler, so + // OTHER sockets remain serviceable while this one's hook chain runs. + await this.routeLlmEvent(eventName, data) }) } @@ -1800,8 +1838,15 @@ export class TaskRouter { * Generic handler for routing LLM events from Agent to clients. * Checks both active and recently completed tasks (within grace period). * onToolResult hooks are called only for ACTIVE tasks (not grace-period). + * + * Async because TOOL_RESULT hooks may do disk I/O (AnalyticsHook reads + * post-op frontmatter). The hook chain is awaited sequentially so a + * caught sync throw OR an awaited rejection both land in the same + * try/catch — no unhandled rejection can escape. Intra-task ordering + * across concurrent TOOL_RESULT events is enforced INSIDE the hook + * (e.g. `AnalyticsHook.pendingByTask` queue), not here. */ - private routeLlmEvent(eventName: string, data: {[key: string]: unknown; taskId: string}): void { + private async routeLlmEvent(eventName: string, data: {[key: string]: unknown; taskId: string}): Promise { const {taskId, ...rest} = data const activeTask = this.tasks.get(taskId) const task = activeTask ?? this.completedTasks.get(taskId)?.task @@ -1818,11 +1863,35 @@ export class TaskRouter { this.accumulateLlmEvent(taskId, eventName, data) } - // Notify onToolResult hooks only for active tasks + // Notify onToolResult hooks only for active tasks. + // + // Two-pass dispatch: (1) call every hook's onToolResult SYNCHRONOUSLY so + // each hook's sync registration code runs before any await yields + // (critical for `AnalyticsHook.pendingByTask` — the per-task queue must + // observe THIS op before a racing TASK_COMPLETED handler reads it); + // (2) await each returned Promise in array order so per-hook async work + // settles before the broadcast and rejections still land in the try/catch. + // The single-pass `for-await` shape would defer hook[N]'s sync body until + // hook[N-1]'s Promise resolves, leaving racing terminal handlers a + // window in which `pendingByTask` is still empty. if (activeTask && eventName === LlmEventNames.TOOL_RESULT) { + const promises: Array | undefined> = [] for (const hook of this.lifecycleHooks) { try { - hook.onToolResult?.(taskId, data as unknown as LlmToolResultEvent) + promises.push(hook.onToolResult?.(taskId, data as unknown as LlmToolResultEvent)) + } catch (error) { + transportLog( + `LifecycleHook.onToolResult sync error for ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + promises.push(undefined) + } + } + + for (const p of promises) { + if (p === undefined) continue + try { + // eslint-disable-next-line no-await-in-loop -- sequential await by design + await p } catch (error) { transportLog( `LifecycleHook.onToolResult error for ${taskId}: ${error instanceof Error ? error.message : String(error)}`, @@ -1831,15 +1900,23 @@ export class TaskRouter { } } - this.transport.sendTo(task.clientId, eventName, {taskId, ...rest}) - broadcastToProjectRoom( - this.projectRegistry, - this.projectRouter, - task.projectPath, - eventName, - {taskId, ...rest}, - task.clientId, - ) + // M17: synthetic LLM events (emitted by tool-mode curate / query so the + // lifecycle-hook chain has inputs) MUST NOT surface in the CLI's + // streamed output, TUI live view, MCP client, or webui — they're + // internal analytics plumbing. Skip the per-client send + broadcast + // when the marker is present; the accumulator and onToolResult hook + // chain above already ran. + if (!isSyntheticLlmEvent(data)) { + this.transport.sendTo(task.clientId, eventName, {taskId, ...rest}) + broadcastToProjectRoom( + this.projectRegistry, + this.projectRouter, + task.projectPath, + eventName, + {taskId, ...rest}, + task.clientId, + ) + } // Reset the heartbeat timer — every forwarded LLM event counts as // activity so a noisy task never triggers a redundant `task:heartbeat`. diff --git a/src/server/infra/process/transport-handlers.ts b/src/server/infra/process/transport-handlers.ts index 07e1c17b2..adfec6db7 100644 --- a/src/server/infra/process/transport-handlers.ts +++ b/src/server/infra/process/transport-handlers.ts @@ -93,6 +93,14 @@ export class TransportHandlers { projectRegistry: options.projectRegistry, projectRouter: options.projectRouter, resolveActiveProvider: options.resolveActiveProvider, + resolveClientIdentity(clientId) { + const client = options.clientManager?.getClient(clientId) + if (!client) return + return { + ...(client.agentName ? {clientName: client.agentName} : {}), + clientType: client.type, + } + }, resolveClientProjectPath: (clientId) => options.clientManager?.getClient(clientId)?.projectPath, transport: options.transport, }) diff --git a/src/server/infra/process/wire-analytics-auth-pre-transition.ts b/src/server/infra/process/wire-analytics-auth-pre-transition.ts new file mode 100644 index 000000000..31efd7901 --- /dev/null +++ b/src/server/infra/process/wire-analytics-auth-pre-transition.ts @@ -0,0 +1,39 @@ +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-store.js' + +/** + * Subscribe the analytics client to the pre-transition hook so it can + * flush events under the OLD session header before the auth state + * commits. + * + * The hook fires for any accessToken change, but we only want to flush + * when the IDENTITY actually changed: login (anon → auth), logout + * (auth → anon), or account switch (userA → userB). A pure access-token + * refresh keeps the same userId and would just waste an HTTP call. + * + * Errors from flush() are swallowed: analytics MUST NOT block the auth + * transition. The store's hang-guard provides the upper bound; this + * helper provides the error-tolerance. + * + * Pairs with `wireAnalyticsAuthTransition` (M4.1): pre-hook ships + * surviving events, then the post-hook drops anything left behind + * (e.g. records the flush couldn't deliver before the backend timed + * out). + */ +export function wireAnalyticsAuthPreTransition( + authStateStore: IAuthStateStore, + analyticsClient: IAnalyticsClient, +): void { + authStateStore.onBeforeAuthChange(async (oldToken, newToken) => { + // Identity-change guard: same userId across the transition (typical + // access-token refresh) is NOT an identity change. Skip the flush. + if (oldToken?.userId === newToken?.userId) return + + try { + await analyticsClient.flush() + } catch { + // Swallowed: analytics failures MUST NOT block auth transitions. + // M4.5 will surface failure reasons via a different channel. + } + }) +} diff --git a/src/server/infra/process/wire-analytics-auth-transition.ts b/src/server/infra/process/wire-analytics-auth-transition.ts new file mode 100644 index 000000000..4d10c2c3d --- /dev/null +++ b/src/server/infra/process/wire-analytics-auth-transition.ts @@ -0,0 +1,38 @@ +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-store.js' + +/** + * Subscribe the analytics client to identity-changing auth transitions. + * + * M4.1 contract: `AuthStateStore.onAuthChanged` fires on login, logout, + * account switch, AND token refresh. Only the identity-changing + * transitions (login / logout / account switch) drop the analytics + * queue. A pure access-token refresh keeps the same user_id and must + * NOT clear pending events. + * + * The closure tracks the previously-seen userId locally so the callback + * distinguishes "same user, new accessToken" (skip) from "different + * identity" (clear). `previousUserId` is seeded from the current cached + * token so the first callback after subscribe doesn't fire a spurious + * clear when the token was already loaded. + * + * Extracted from `feature-handlers.ts` so the wiring is testable in + * isolation — booting the full feature-handler graph would require + * stubbing every HTTP service and config store the daemon uses. Keeping + * this a 1-call function with two collaborators makes the + * `IAuthStateStore` multi-listener contract (M4.1) testable end-to-end + * without infrastructure ceremony. + */ +export function wireAnalyticsAuthTransition( + authStateStore: IAuthStateStore, + analyticsClient: IAnalyticsClient, +): void { + let previousUserId: string | undefined = authStateStore.getToken()?.userId + authStateStore.onAuthChanged((token) => { + const nextUserId = token?.userId + if (nextUserId === previousUserId) return + previousUserId = nextUserId + // eslint-disable-next-line no-void + void analyticsClient.onAuthTransition() + }) +} diff --git a/src/server/infra/process/wire-analytics-flush-scheduler.ts b/src/server/infra/process/wire-analytics-flush-scheduler.ts new file mode 100644 index 000000000..2f48a35ca --- /dev/null +++ b/src/server/infra/process/wire-analytics-flush-scheduler.ts @@ -0,0 +1,86 @@ +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' +import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' + +import {AnalyticsFlushScheduler} from '../analytics/analytics-flush-scheduler.js' + +export type AnalyticsFlushSchedulerWiring = { + analyticsClient: IAnalyticsClient + /** + * M4.5: optional backoff policy. When wired, the scheduler arms its + * next tick from `policy.nextDelayMs()` so a failing backend stretches + * the inter-tick gap to 60s → 2m → 5m (capped). The `AnalyticsClient` + * already feeds this same policy from inside `runFlush`, so the + * scheduler just reads the live value. + * + * Omitted in tests / dev experiments → scheduler keeps its fixed + * 30s default. + */ + backoffPolicy?: IAnalyticsBackoffPolicy + isEnabled: () => boolean + /** + * JSONL store used to count pending rows for the empty-skip gate. The + * scheduler uses `loadPending().length` (NOT `queue.size()`) because + * the in-memory queue mirror never decrements after a successful flush, + * which would make the interval timer fire 30s indefinitely with + * nothing left to ship. + */ + jsonlStore: IJsonlAnalyticsStore + /** + * Direct override for the per-tick delay. Tests pass a closure to + * exercise dynamic intervals without standing up a real policy. + * Production code should pass `backoffPolicy` instead. + * + * If both `backoffPolicy` and `nextIntervalMs` are wired, + * `backoffPolicy` wins. + */ + nextIntervalMs?: () => number + queue: IAnalyticsQueue + /** Override the 20-event threshold (default) for tests / dev experiments. */ + thresholdCount?: number +} + +/** + * Compose the M4.3 flush scheduler. + * + * The scheduler is the orchestrator that decides WHEN to flush; it + * delegates the actual flush work to `IAnalyticsClient.flush()`. Two + * triggers (whichever first): + * - 30s interval timer + * - 20-event queue depth + * + * Returned `AnalyticsFlushScheduler` is owned by the composition root: + * - call `start()` after the AnalyticsClient is wired (so the first + * tick has a working sender). + * - call `stop()` in the shutdown sequence before `flushFinal()` so + * no new ticks fire mid-shutdown. + * + * Extracted from `feature-handlers.ts` so the wiring is testable in + * isolation — mirrors the M4.1 / M4.2 wiring helper pattern. + */ +export function wireAnalyticsFlushScheduler( + wiring: AnalyticsFlushSchedulerWiring, +): AnalyticsFlushScheduler { + // M4.5 precedence: a real backoffPolicy wins over a literal + // nextIntervalMs override. This keeps test ergonomics simple + // (pass `nextIntervalMs: () => 50` for fast tests) while production + // wiring (`backoffPolicy` only) reads the policy at arm-time. + // Capture `policy` in a const so the arrow closure keeps the narrowed + // type (avoiding the `!` non-null assertion that CLAUDE.md discourages). + const policy = wiring.backoffPolicy + const nextIntervalMs = policy === undefined ? wiring.nextIntervalMs : (): number => policy.nextDelayMs() + return new AnalyticsFlushScheduler({ + flush: () => wiring.analyticsClient.flush(), + isEnabled: wiring.isEnabled, + // M5.4 (ENG-2658): wire the burst-trigger rate-limit gate to the policy so a + // 429/503 suppresses threshold flushes (the stretched periodic tick ships + // the backlog). Omitted when there's no policy; the scheduler then defaults + // to never-rate-limited. + ...(policy === undefined ? {} : {isRateLimited: (): boolean => policy.isRateLimited()}), + ...(nextIntervalMs === undefined ? {} : {nextIntervalMs}), + pendingCount: async () => (await wiring.jsonlStore.loadPending()).length, + queueSize: () => wiring.queue.size(), + ...(wiring.thresholdCount === undefined ? {} : {thresholdCount: wiring.thresholdCount}), + }) +} diff --git a/src/server/infra/process/wire-analytics-http-sender.ts b/src/server/infra/process/wire-analytics-http-sender.ts new file mode 100644 index 000000000..bab1ebda0 --- /dev/null +++ b/src/server/infra/process/wire-analytics-http-sender.ts @@ -0,0 +1,59 @@ +import type {IAnalyticsSender} from '../../core/interfaces/analytics/i-analytics-sender.js' +import type {IAuthStateReader} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +import {AxiosAnalyticsHttpClient} from '../analytics/axios-analytics-http-client.js' +import {DrainingAnalyticsSender} from '../analytics/draining-analytics-sender.js' +import {HttpAnalyticsSender} from '../analytics/http-analytics-sender.js' + +export type AnalyticsHttpSenderWiring = { + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. `undefined` signals "no working + * remote endpoint" (env unset, empty, or malformed — see + * `resolveAnalyticsBaseUrl`). The factory then returns a + * `DrainingAnalyticsSender` and the axios client is never constructed. + */ + analyticsBaseUrl: string | undefined + authStateReader: IAuthStateReader + globalConfigStore: IGlobalConfigStore + /** CLI semver string (e.g. `3.12.0`). Wrapped into the user-agent header. */ + version: string +} + +/** + * Compose the production analytics sender stack: + * AxiosAnalyticsHttpClient (transport — axios POST, 5s timeout, status + * classification) wrapped by HttpAnalyticsSender (sender contract — + * batch composition + header assembly). + * + * Extracted from `feature-handlers.ts` so the wiring is testable in + * isolation. Booting the full feature-handler graph would require + * stubbing every HTTP service the daemon uses; this helper exposes only + * the analytics-relevant collaborators so unit tests can assert the + * composition shape without infrastructure ceremony. + * + * Mirrors the M4.1 `wireAnalyticsAuthTransition` precedent — every + * composition-root binding gets a thin pure factory + a focused test so + * a future swap (e.g. swapping axios for undici, or wrapping the sender + * for M4.5 backoff) lands at one obvious seam. + * + * When `wiring.analyticsBaseUrl === undefined` (env unset, empty, or + * malformed) the factory short-circuits to `DrainingAnalyticsSender` so the + * axios client is never constructed and no outbound HTTP fires. Local + * JSONL tracking via `AnalyticsClient.track()` keeps working unchanged; + * the draining sender drains the pending queue on each flush. + * + * The returned value is the `IAnalyticsSender` consumed by + * `AnalyticsClient.flush()`. + */ +export function wireAnalyticsHttpSender(wiring: AnalyticsHttpSenderWiring): IAnalyticsSender { + if (wiring.analyticsBaseUrl === undefined) return new DrainingAnalyticsSender() + + const httpClient = new AxiosAnalyticsHttpClient({baseUrl: wiring.analyticsBaseUrl}) + return new HttpAnalyticsSender({ + authStateReader: wiring.authStateReader, + globalConfigStore: wiring.globalConfigStore, + httpClient, + userAgent: `brv-cli/${wiring.version}`, + }) +} diff --git a/src/server/infra/state/auth-state-store.ts b/src/server/infra/state/auth-state-store.ts index 6e85f385b..057bfba6a 100644 --- a/src/server/infra/state/auth-state-store.ts +++ b/src/server/infra/state/auth-state-store.ts @@ -3,12 +3,22 @@ import type {ITokenStore} from '../../core/interfaces/auth/i-token-store.js' import type { AuthChangedCallback, AuthExpiredCallback, + BeforeAuthChangedCallback, IAuthStateStore, } from '../../core/interfaces/state/i-auth-state-store.js' import {AUTH_STATE_POLL_INTERVAL_MS} from '../../constants.js' +const DEFAULT_BEFORE_AUTH_CHANGE_TIMEOUT_MS = 6000 + type AuthStateStoreOptions = { + /** + * Hang-guard for `onBeforeAuthChange` listeners. Each pre-listener is + * raced against this timeout so a wedged subsystem (e.g. analytics + * flush stuck on a slow backend) cannot deadlock auth transitions. + * Default 6000ms = HTTP-client 5s timeout + 1s slack. + */ + beforeAuthChangeTimeoutMs?: number /** Logging function (optional, defaults to no-op) */ log?: (message: string) => void /** Polling interval in milliseconds (optional, defaults to AUTH_STATE_POLL_INTERVAL_MS) */ @@ -33,8 +43,10 @@ type AuthStateStoreOptions = { * Uses an isPolling guard to prevent overlapping poll cycles. */ export class AuthStateStore implements IAuthStateStore { - private authChangedCallback: AuthChangedCallback | undefined - private authExpiredCallback: AuthExpiredCallback | undefined + private readonly authChangedCallbacks: AuthChangedCallback[] = [] + private readonly authExpiredCallbacks: AuthExpiredCallback[] = [] + private readonly beforeAuthChangeCallbacks: BeforeAuthChangedCallback[] = [] + private readonly beforeAuthChangeTimeoutMs: number private cachedToken: AuthToken | undefined private isPolling = false private readonly log: (message: string) => void @@ -47,6 +59,7 @@ export class AuthStateStore implements IAuthStateStore { constructor(options: AuthStateStoreOptions) { this.tokenStore = options.tokenStore this.pollIntervalMs = options.pollIntervalMs ?? AUTH_STATE_POLL_INTERVAL_MS + this.beforeAuthChangeTimeoutMs = options.beforeAuthChangeTimeoutMs ?? DEFAULT_BEFORE_AUTH_CHANGE_TIMEOUT_MS this.log = options.log ?? (() => {}) } @@ -57,7 +70,7 @@ export class AuthStateStore implements IAuthStateStore { async loadToken(): Promise { try { const token = await this.tokenStore.load() - this.updateCachedToken(token) + await this.updateCachedToken(token) return this.cachedToken } catch (error) { this.log(`Failed to load token: ${error instanceof Error ? error.message : String(error)}`) @@ -66,11 +79,15 @@ export class AuthStateStore implements IAuthStateStore { } onAuthChanged(callback: AuthChangedCallback): void { - this.authChangedCallback = callback + this.authChangedCallbacks.push(callback) } onAuthExpired(callback: AuthExpiredCallback): void { - this.authExpiredCallback = callback + this.authExpiredCallbacks.push(callback) + } + + onBeforeAuthChange(callback: BeforeAuthChangedCallback): void { + this.beforeAuthChangeCallbacks.push(callback) } startPolling(): void { @@ -93,6 +110,70 @@ export class AuthStateStore implements IAuthStateStore { this.log('Auth state polling stopped') } + /** + * Dispatch to every registered onAuthChanged listener. One listener + * throwing must NOT prevent the others from firing or break the + * polling loop; we log and continue. + */ + private fireAuthChanged(token: AuthToken | undefined): void { + for (const callback of this.authChangedCallbacks) { + try { + callback(token) + } catch (error) { + this.log(`onAuthChanged callback threw: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + private fireAuthExpired(token: AuthToken): void { + for (const callback of this.authExpiredCallbacks) { + try { + callback(token) + } catch (error) { + this.log(`onAuthExpired callback threw: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + /** + * Fire pre-transition listeners in registration order, each bounded by + * `beforeAuthChangeTimeoutMs`. A listener that rejects or hangs is + * logged best-effort and does NOT block subsequent listeners or the + * transition itself — this is the contract the analytics force-flush + * relies on (must not deadlock auth on a wedged backend). + * + * The cached token is NOT mutated yet — `getToken()` still returns the + * old token throughout this call. That guarantee is what lets the + * analytics flush carry the OLD session header. + */ + private async fireBeforeAuthChange( + oldToken: AuthToken | undefined, + newToken: AuthToken | undefined, + ): Promise { + for (const callback of this.beforeAuthChangeCallbacks) { + let timer: ReturnType | undefined + try { + // eslint-disable-next-line no-await-in-loop + await Promise.race([ + Promise.resolve(callback(oldToken, newToken)), + new Promise((resolve) => { + timer = setTimeout(resolve, this.beforeAuthChangeTimeoutMs) + }), + ]) + } catch (error) { + this.log(`onBeforeAuthChange callback rejected: ${error instanceof Error ? error.message : String(error)}`) + } finally { + // Always clear the hang-guard timer when the callback wins the + // race (the common fast path). Without this clear, every + // transition leaks a pending Node timer that keeps the event + // loop alive for `beforeAuthChangeTimeoutMs` after the callback + // settled — a shutdown triggered shortly after a transition + // would block up to that budget waiting for the phantom timer. + if (timer !== undefined) clearTimeout(timer) + } + } + } + /** * Single poll cycle. Loads token from store and compares with cached. * Skips if a poll is already in-flight. @@ -103,7 +184,7 @@ export class AuthStateStore implements IAuthStateStore { this.isPolling = true try { const token = await this.tokenStore.load() - this.updateCachedToken(token) + await this.updateCachedToken(token) } catch (error) { this.log(`Auth poll error: ${error instanceof Error ? error.message : String(error)}`) } finally { @@ -112,26 +193,36 @@ export class AuthStateStore implements IAuthStateStore { } /** - * Compare loaded token with cached and fire appropriate callbacks. + * Compare loaded token with cached, fire pre-transition listeners + * (M4.4), then mutate the cache and fire post-transition listeners. + * + * Ordering is load-bearing: the pre-listeners observe the OLD token + * via `getToken()` because `this.cachedToken` only mutates AFTER they + * resolve. Without that ordering, M4.4's flush-then-drop hybrid would + * ship events with the NEW session header but OLD per-event identity, + * tripping the backend's identity-mismatch path and downgrading those + * events to anonymous. */ - private updateCachedToken(token: AuthToken | undefined): void { + private async updateCachedToken(token: AuthToken | undefined): Promise { const previousAccessToken = this.cachedToken?.accessToken const newAccessToken = token?.accessToken // Detect change: different accessToken (including undefined <-> defined) if (previousAccessToken !== newAccessToken) { + const oldToken = this.cachedToken + await this.fireBeforeAuthChange(oldToken, token) this.cachedToken = token this.wasExpired = false this.log(`Auth state changed: ${token ? 'token present' : 'token removed'}`) - this.authChangedCallback?.(token) + this.fireAuthChanged(token) return } - // Same token — check for expiry transition + // Same token, check for expiry transition if (token && token.isExpired() && !this.wasExpired) { this.wasExpired = true this.log('Auth token expired') - this.authExpiredCallback?.(token) + this.fireAuthExpired(token) } // Update cached reference (same accessToken but other fields may differ) diff --git a/src/server/infra/storage/file-settings-store.ts b/src/server/infra/storage/file-settings-store.ts index 46ca7a34b..c1c89c9c3 100644 --- a/src/server/infra/storage/file-settings-store.ts +++ b/src/server/infra/storage/file-settings-store.ts @@ -3,13 +3,17 @@ import {existsSync} from 'node:fs' import {mkdir, readFile, rename, unlink, writeFile} from 'node:fs/promises' import {join} from 'node:path' -import type {SettingItem} from '../../core/domain/entities/settings.js' +import type {SettingDescriptor, SettingItem} from '../../core/domain/entities/settings.js' import type {ISettingsStore, SettingsStartupSnapshot} from '../../core/interfaces/storage/i-settings-store.js' import {SETTINGS_FILE, SETTINGS_SCHEMA_VERSION} from '../../constants.js' import {SETTINGS_REGISTRY} from '../../core/domain/entities/settings.js' import {getGlobalDataDir} from '../../utils/global-data-path.js' -import {InvalidSettingValueError, SettingsValidator} from './settings-validator.js' +import { + InvalidSettingValueError, + ReadonlySettingKeyError, + SettingsValidator, +} from './settings-validator.js' type SettingsFile = { /** @@ -25,6 +29,13 @@ type SettingsFile = { export type FileSettingsStoreOptions = { readonly baseDir?: string + /** + * Override the descriptor registry. Defaults to the production + * `SETTINGS_REGISTRY`. Tests inject a small registry containing the + * variant under test (e.g. a `readonly-info` descriptor) so per-key + * behaviour can be exercised in isolation. + */ + readonly registry?: readonly SettingDescriptor[] readonly validator?: SettingsValidator } @@ -46,18 +57,38 @@ type RawReadResult = * atomic temp-file + rename write. Reads return defaults for any key that is * missing or invalid in the file; surfacing invalid entries (for warning * logs) is the daemon-startup loader's job, not this store's. + * + * Readonly-info descriptors are surfaced in `get` / `list` with + * `current = undefined` and no `default`; the handler is responsible for + * resolving the live snapshot via its injected info-provider map. `set` + * and `reset` on a readonly-info key throw `ReadonlySettingKeyError` + * without touching the on-disk file. */ export class FileSettingsStore implements ISettingsStore { private readonly baseDir: string + private readonly registry: readonly SettingDescriptor[] private readonly validator: SettingsValidator public constructor(options: FileSettingsStoreOptions = {}) { this.baseDir = options.baseDir ?? getGlobalDataDir() - this.validator = options.validator ?? new SettingsValidator() + this.registry = options.registry ?? SETTINGS_REGISTRY + this.validator = options.validator ?? new SettingsValidator({registry: this.registry}) } public async get(key: string): Promise { const descriptor = this.validator.validateKey(key) + // readonly-info: value comes from the handler's info-provider map. + // global-config: value comes from GlobalConfigHandler via the settings handler facade. + // Either way, the file store has no value to surface — return the + // inert row shape so the handler can resolve through the right path. + if (descriptor.type === 'readonly-info') { + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} + } + + if (descriptor.storage === 'global-config') { + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} + } + const overrides = await this.readOverrides() return { current: overrides[key] ?? descriptor.default, @@ -69,12 +100,22 @@ export class FileSettingsStore implements ISettingsStore { public async list(): Promise { const overrides = await this.readOverrides() - return SETTINGS_REGISTRY.map((descriptor) => ({ - current: overrides[descriptor.key] ?? descriptor.default, - default: descriptor.default, - key: descriptor.key, - restartRequired: true, - })) + return this.registry.map((descriptor) => { + if (descriptor.type === 'readonly-info') { + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} + } + + if (descriptor.storage === 'global-config') { + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} + } + + return { + current: overrides[descriptor.key] ?? descriptor.default, + default: descriptor.default, + key: descriptor.key, + restartRequired: true, + } + }) } public async readStartupSnapshot(): Promise { @@ -87,7 +128,19 @@ export class FileSettingsStore implements ISettingsStore { } public async reset(key: string): Promise { - this.validator.validateKey(key) + const descriptor = this.validator.validateKey(key) + if (descriptor.type === 'readonly-info') { + throw new ReadonlySettingKeyError(key) + } + + if (descriptor.storage === 'global-config') { + throw new InvalidSettingValueError( + key, + undefined, + `'${key}' is stored in config.json, not settings.json; use the SettingsHandler facade`, + ) + } + const raw = await this.readRawValues() if (!(key in raw)) return diff --git a/src/server/infra/storage/settings-validator.ts b/src/server/infra/storage/settings-validator.ts index c37fc2762..590445394 100644 --- a/src/server/infra/storage/settings-validator.ts +++ b/src/server/infra/storage/settings-validator.ts @@ -4,7 +4,7 @@ import type { SettingDescriptor, } from '../../core/domain/entities/settings.js' -import {findSettingDescriptor, SETTINGS_KEYS} from '../../core/domain/entities/settings.js' +import {SETTINGS_KEYS, SETTINGS_REGISTRY} from '../../core/domain/entities/settings.js' export class UnknownSettingKeyError extends Error { public readonly key: string @@ -28,6 +28,23 @@ export class InvalidSettingValueError extends Error { } } +/** + * Raised when a caller tries to mutate a `readonly-info` descriptor via + * `validate`, the store's `set` / `reset`, or any future write surface. + * Distinct from `InvalidSettingValueError` so the transport handler can + * surface a typed `code: 'read_only'` response without string-matching + * the message. + */ +export class ReadonlySettingKeyError extends Error { + public readonly key: string + + public constructor(key: string) { + super(`Setting '${key}' is read-only and cannot be written or reset.`) + this.name = 'ReadonlySettingKeyError' + this.key = key + } +} + export type PartitionedSettings = { readonly invalid: ReadonlyArray<{readonly key: string; readonly reason: string; readonly value: unknown}> readonly valid: Readonly> @@ -38,6 +55,17 @@ export type CouplingViolation = { readonly reason: string } +export type SettingsValidatorOptions = { + /** + * Override the descriptor registry. Defaults to the production + * `SETTINGS_REGISTRY`. Tests inject a small registry containing the + * variant under test (e.g. a single `readonly-info` descriptor) so + * partition + validate behaviour can be exercised without polluting + * the production registry. + */ + readonly registry?: readonly SettingDescriptor[] +} + const COUPLING_REQUEST_TIMEOUT = SETTINGS_KEYS.LLM_REQUEST_TIMEOUT_MS const COUPLING_ITERATION_BUDGET = SETTINGS_KEYS.LLM_ITERATION_BUDGET_MS @@ -50,6 +78,12 @@ const COUPLING_ITERATION_BUDGET = SETTINGS_KEYS.LLM_ITERATION_BUDGET_MS * when M3 lands; the store and the transport handler do not need to change. */ export class SettingsValidator { + private readonly registry: readonly SettingDescriptor[] + + public constructor(options: SettingsValidatorOptions = {}) { + this.registry = options.registry ?? SETTINGS_REGISTRY + } + /** * Splits a raw record (e.g. parsed from `settings.json`) into the valid * entries the daemon should apply and the invalid entries the daemon should @@ -60,14 +94,24 @@ export class SettingsValidator { const invalid: Array<{key: string; reason: string; value: unknown}> = [] for (const [key, value] of Object.entries(record)) { - const descriptor = findSettingDescriptor(key) + const descriptor = this.findDescriptor(key) if (descriptor === undefined) { invalid.push({key, reason: 'unknown settings key', value}) continue } + if (descriptor.type === 'readonly-info') { + invalid.push({key, reason: 'readonly-info key cannot be persisted', value}) + continue + } + + if (descriptor.storage === 'global-config') { + invalid.push({key, reason: `'${key}' is stored in config.json, not settings.json`, value}) + continue + } + try { - valid[key] = this.validateAgainst(descriptor, value) + valid[key] = validateWritableAgainst(descriptor, value) } catch (error) { if (error instanceof InvalidSettingValueError) { invalid.push({key, reason: error.message, value: error.value}) @@ -95,13 +139,25 @@ export class SettingsValidator { } /** - * Validates a single key/value pair. Throws on unknown key or invalid value. - * Returns the coerced value on success (integer for integer descriptors, - * boolean for boolean descriptors). + * Validates a single key/value pair. Throws on unknown key, read-only key, + * or invalid value. Returns the coerced value on success (integer for + * integer descriptors, boolean for boolean descriptors). */ public validate(key: string, value: unknown): boolean | number { const descriptor = this.validateKey(key) - return this.validateAgainst(descriptor, value) + if (descriptor.type === 'readonly-info') { + throw new ReadonlySettingKeyError(key) + } + + if (descriptor.storage === 'global-config') { + throw new InvalidSettingValueError( + key, + value, + `'${key}' is stored in config.json, not settings.json; use the SettingsHandler facade`, + ) + } + + return validateWritableAgainst(descriptor, value) } /** @@ -113,8 +169,15 @@ export class SettingsValidator { public validateCoupling(values: Readonly>): readonly CouplingViolation[] { const violations: CouplingViolation[] = [] - const requestTimeout = values[COUPLING_REQUEST_TIMEOUT] ?? findSettingDescriptor(COUPLING_REQUEST_TIMEOUT)?.default - const iterationBudget = values[COUPLING_ITERATION_BUDGET] ?? findSettingDescriptor(COUPLING_ITERATION_BUDGET)?.default + const requestTimeoutDescriptor = this.findDescriptor(COUPLING_REQUEST_TIMEOUT) + const iterationBudgetDescriptor = this.findDescriptor(COUPLING_ITERATION_BUDGET) + const requestTimeoutDefault = + requestTimeoutDescriptor?.type === 'integer' ? requestTimeoutDescriptor.default : undefined + const iterationBudgetDefault = + iterationBudgetDescriptor?.type === 'integer' ? iterationBudgetDescriptor.default : undefined + + const requestTimeout = values[COUPLING_REQUEST_TIMEOUT] ?? requestTimeoutDefault + const iterationBudget = values[COUPLING_ITERATION_BUDGET] ?? iterationBudgetDefault if (requestTimeout !== undefined && iterationBudget !== undefined && requestTimeout > iterationBudget) { violations.push({ @@ -131,17 +194,24 @@ export class SettingsValidator { * key is not registered. */ public validateKey(key: string): SettingDescriptor { - const descriptor = findSettingDescriptor(key) + const descriptor = this.findDescriptor(key) if (descriptor === undefined) throw new UnknownSettingKeyError(key) return descriptor } - private validateAgainst(descriptor: SettingDescriptor, value: unknown): boolean | number { - if (descriptor.type === 'boolean') return validateBoolean(descriptor, value) - return validateInteger(descriptor, value) + private findDescriptor(key: string): SettingDescriptor | undefined { + return this.registry.find((d) => d.key === key) } } +function validateWritableAgainst( + descriptor: BooleanSettingDescriptor | IntegerSettingDescriptor, + value: unknown, +): boolean | number { + if (descriptor.type === 'boolean') return validateBoolean(descriptor, value) + return validateInteger(descriptor, value) +} + function validateInteger(descriptor: IntegerSettingDescriptor, value: unknown): number { if (typeof value !== 'number' || !Number.isInteger(value)) { throw new InvalidSettingValueError( diff --git a/src/server/infra/transport/cli-invocation-middleware.ts b/src/server/infra/transport/cli-invocation-middleware.ts new file mode 100644 index 000000000..473e73d92 --- /dev/null +++ b/src/server/infra/transport/cli-invocation-middleware.ts @@ -0,0 +1,86 @@ + +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' +import {CliInvocationSchema} from '../../../shared/analytics/events/cli-invocation.js' +import {processLog} from '../../utils/process-logger.js' + +export type CliInvocationMiddlewareDeps = { + /** + * Lazy getter for the analytics client. Resolved per-request because the + * client is constructed AFTER the middleware is attached (during + * setupFeatureHandlers); a value-bound dep would capture `undefined` + * forever. + */ + getAnalyticsClient: () => IAnalyticsClient | undefined +} + +type OnRequestFn = ITransportServer['onRequest'] + +/** + * Symbol marker stamped on the wrapped `onRequest` so a second + * `attachCliInvocationMiddleware(server, ...)` call can detect the prior + * attach and bail. Without this, the second call would wrap the + * already-wrapped function and double-fire `cli_invocation` per request. + * + * `Symbol.for(...)` (not `Symbol(...)`) so the marker survives module + * re-loads in test harnesses that re-import the file. + */ +const CLI_INVOCATION_ATTACHED = Symbol.for('M15.8/cli-invocation-middleware-attached') + +type MarkedOnRequest = OnRequestFn & {[CLI_INVOCATION_ATTACHED]?: true} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** + * M15.8 — wrap `transportServer.onRequest` so every incoming payload is + * inspected for a `cli_metadata` block. When present and Zod-valid, emit + * `cli_invocation` BEFORE forwarding to the real handler. The original + * handler is invoked even on parse failure or when analytics is off — + * analytics is opportunistic, never blocking. + * + * Idempotent: a second call on the same transport server is a no-op (a + * marker symbol on the wrapped function flags the prior attach). + */ +export function attachCliInvocationMiddleware( + transportServer: ITransportServer, + deps: CliInvocationMiddlewareDeps, +): void { + const current = transportServer.onRequest as MarkedOnRequest + if (current[CLI_INVOCATION_ATTACHED]) return + + const original = current.bind(transportServer) + const wrappedOnRequest: MarkedOnRequest = ( + event: string, + handler: RequestHandler, + ): void => { + const wrapped: RequestHandler = (data, clientId) => { + maybeEmitCliInvocation(data, deps.getAnalyticsClient()) + return handler(data, clientId) + } + + original(event, wrapped) + } + + wrappedOnRequest[CLI_INVOCATION_ATTACHED] = true + transportServer.onRequest = wrappedOnRequest +} + +function maybeEmitCliInvocation(data: unknown, client: IAnalyticsClient | undefined): void { + if (client === undefined) return + if (!isRecord(data)) return + if (!('cli_metadata' in data)) return + + const parsed = CliInvocationSchema.safeParse(data.cli_metadata) + if (!parsed.success) return + + try { + client.track(AnalyticsEventNames.CLI_INVOCATION, parsed.data) + } catch (error) { + processLog( + `cli_invocation middleware track failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} diff --git a/src/server/infra/transport/client-kind-context.ts b/src/server/infra/transport/client-kind-context.ts new file mode 100644 index 000000000..11847ae79 --- /dev/null +++ b/src/server/infra/transport/client-kind-context.ts @@ -0,0 +1,32 @@ +/* eslint-disable camelcase */ +import {AsyncLocalStorage} from 'node:async_hooks' + +import type {ClientType} from '../../core/domain/client/client-info.js' + +/** + * Async-context scope for the daemon-stamped Socket.IO `client_kind`. + * + * The Socket.IO transport layer wraps every incoming transport-handler + * invocation in `clientKindContext.run({client_kind}, ...)` keyed off + * the originating socket's registered ClientType. SuperPropertiesResolver + * reads the value during super-property resolution so every analytics + * event automatically carries the originating client kind on its outer + * envelope — no per-handler signature change required. + * + * Same propagation model as `reviewDisabledStorage` in + * src/agent/infra/tools/implementations/curate-tool-task-context.ts. + * + * Outside any scope, `getClientKindFromContext()` returns `undefined` + * and the resolver omits `client_kind` from the stamped envelope — + * exercised by the agent-fork bypass and any direct daemon-internal + * track() call that does not originate from a Socket.IO event. + */ +export const clientKindContext = new AsyncLocalStorage<{client_kind: ClientType}>() + +export function runWithClientKind(client_kind: ClientType, fn: () => Promise): Promise { + return clientKindContext.run({client_kind}, fn) +} + +export function getClientKindFromContext(): ClientType | undefined { + return clientKindContext.getStore()?.client_kind +} diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts new file mode 100644 index 000000000..ede49b5bc --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -0,0 +1,465 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {AnalyticsDisabledSchema} from '../../../../shared/analytics/events/analytics-disabled.js' +import {AuthLoginSchema} from '../../../../shared/analytics/events/auth-login.js' +import {AuthLogoutSchema} from '../../../../shared/analytics/events/auth-logout.js' +import {BrvInitSchema} from '../../../../shared/analytics/events/brv-init.js' +import {CliInvocationSchema} from '../../../../shared/analytics/events/cli-invocation.js' +import {ConnectorInstalledSchema} from '../../../../shared/analytics/events/connector-installed.js' +import {ContentMigratedSchema} from '../../../../shared/analytics/events/content-migrated.js' +import {ContextTreeFileEditedSchema} from '../../../../shared/analytics/events/context-tree-file-edited.js' +import {CurateOperationAppliedSchema} from '../../../../shared/analytics/events/curate-operation-applied.js' +import {CurateRunCompletedSchema} from '../../../../shared/analytics/events/curate-run-completed.js' +import {DaemonResetExecutedSchema} from '../../../../shared/analytics/events/daemon-reset-executed.js' +import {DaemonStartSchema} from '../../../../shared/analytics/events/daemon-start.js' +import {HubPackageInstalledSchema} from '../../../../shared/analytics/events/hub-package-installed.js' +import {HubRegistryAddedSchema} from '../../../../shared/analytics/events/hub-registry-added.js' +import {HubRegistryRemovedSchema} from '../../../../shared/analytics/events/hub-registry-removed.js' +import {isAnalyticsEventName} from '../../../../shared/analytics/events/index.js' +import {McpSessionEndedSchema} from '../../../../shared/analytics/events/mcp-session-ended.js' +import {McpSessionStartSchema} from '../../../../shared/analytics/events/mcp-session-start.js' +import {McpToolCalledSchema} from '../../../../shared/analytics/events/mcp-tool-called.js' +import {MigrateRunSchema} from '../../../../shared/analytics/events/migrate-run.js' +import {OnboardingAutoSetupStartedSchema} from '../../../../shared/analytics/events/onboarding-auto-setup-started.js' +import {OnboardingCompletedSchema} from '../../../../shared/analytics/events/onboarding-completed.js' +import {QueryCompletedSchema} from '../../../../shared/analytics/events/query-completed.js' +import {ReviewApprovedSchema} from '../../../../shared/analytics/events/review-approved.js' +import {ReviewRejectedSchema} from '../../../../shared/analytics/events/review-rejected.js' +import {ReviewToggledSchema} from '../../../../shared/analytics/events/review-toggled.js' +import {SettingChangedSchema} from '../../../../shared/analytics/events/setting-changed.js' +import {SettingResetSchema} from '../../../../shared/analytics/events/setting-reset.js' +import {SourceAddedSchema} from '../../../../shared/analytics/events/source-added.js' +import {SourceRemovedSchema} from '../../../../shared/analytics/events/source-removed.js' +import {SpaceSwitchedSchema} from '../../../../shared/analytics/events/space-switched.js' +import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' +import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' +import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' +import {VcBranchedSchema} from '../../../../shared/analytics/events/vc-branched.js' +import {VcCheckedOutSchema} from '../../../../shared/analytics/events/vc-checked-out.js' +import {VcClonedSchema} from '../../../../shared/analytics/events/vc-cloned.js' +import {VcCommitSchema} from '../../../../shared/analytics/events/vc-commit.js' +import {VcDiscardedSchema} from '../../../../shared/analytics/events/vc-discarded.js' +import {VcFetchedSchema} from '../../../../shared/analytics/events/vc-fetched.js' +import {VcInitSchema} from '../../../../shared/analytics/events/vc-init.js' +import {VcMergedSchema} from '../../../../shared/analytics/events/vc-merged.js' +import {VcPulledSchema} from '../../../../shared/analytics/events/vc-pulled.js' +import {VcPushedSchema} from '../../../../shared/analytics/events/vc-pushed.js' +import {VcRemoteChangedSchema} from '../../../../shared/analytics/events/vc-remote-changed.js' +import {VcResetExecutedSchema} from '../../../../shared/analytics/events/vc-reset-executed.js' +import {WebuiSessionEndedSchema} from '../../../../shared/analytics/events/webui-session-ended.js' +import {WebuiSessionStartedSchema} from '../../../../shared/analytics/events/webui-session-started.js' +import {WorktreeAddedSchema} from '../../../../shared/analytics/events/worktree-added.js' +import {WorktreeRemovedSchema} from '../../../../shared/analytics/events/worktree-removed.js' +import { + AnalyticsEvents, + type AnalyticsTrackPayload, + AnalyticsTrackPayloadSchema, +} from '../../../../shared/transport/events/analytics-events.js' + +export interface AnalyticsHandlerDeps { + analyticsClient: IAnalyticsClient + transport: ITransportServer +} + +/** + * Daemon-side handler for `analytics:track`. Routes validated payloads to the + * daemon-scoped AnalyticsClient, which stamps identity + super-properties and + * enqueues for later flush. + * + * Validation runs at two layers: + * 1. Wire envelope (`AnalyticsTrackPayloadSchema`) — event is non-empty + * string, properties is record-or-undefined. + * 2. Per-event (`ALL_EVENT_SCHEMAS[event]`) — exact property shape for the + * registered event. Unknown events and shape mismatches are dropped here, + * so the daemon's typed `track()` always receives a valid pair. + * + * The dispatch switch covers every entry in `AnalyticsEventNames`, including + * deferred scaffolding events that have a schema but no daemon-side producer + * yet. Wire-side validation is in place for the moment the producer ticket lands. + * + * Malformed payloads and any throw from track() are silently dropped: + * analytics MUST NOT crash the emitting client. + */ +export class AnalyticsHandler { + private readonly analyticsClient: IAnalyticsClient + private readonly transport: ITransportServer + + public constructor(deps: AnalyticsHandlerDeps) { + this.analyticsClient = deps.analyticsClient + this.transport = deps.transport + } + + public setup(): void { + this.transport.onRequest(AnalyticsEvents.TRACK, async (data: unknown) => { + const parsed = AnalyticsTrackPayloadSchema.safeParse(data) + if (!parsed.success) return + + const {event, properties: rawProperties} = parsed.data + if (!isAnalyticsEventName(event)) return + + try { + this.dispatch(event, rawProperties) + } catch { + // Defensive: never crash the emitter. + } + }) + } + + /** + * Per-event Zod validation + typed dispatch into `IAnalyticsClient.track`. + * Each branch re-uses the catalog's per-event schema so the data flowing + * into `track()` matches the discriminated-union contract at compile time — + * no `as` casts. + */ + // eslint-disable-next-line complexity + private dispatch(event: AnalyticsEventName, rawProperties: unknown): void { + switch (event) { + case AnalyticsEventNames.ANALYTICS_DISABLED: { + const props = AnalyticsDisabledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.ANALYTICS_DISABLED) + break + } + + case AnalyticsEventNames.AUTH_LOGIN: { + const props = AuthLoginSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.AUTH_LOGIN, props.data) + break + } + + case AnalyticsEventNames.AUTH_LOGOUT: { + const props = AuthLogoutSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.AUTH_LOGOUT, props.data) + break + } + + case AnalyticsEventNames.BRV_INIT: { + const props = BrvInitSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.BRV_INIT, props.data) + break + } + + case AnalyticsEventNames.CLI_INVOCATION: { + const props = CliInvocationSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CLI_INVOCATION, props.data) + break + } + + case AnalyticsEventNames.CONNECTOR_INSTALLED: { + const props = ConnectorInstalledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CONNECTOR_INSTALLED, props.data) + break + } + + case AnalyticsEventNames.CONTENT_MIGRATED: { + const props = ContentMigratedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CONTENT_MIGRATED, props.data) + break + } + + case AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED: { + const props = ContextTreeFileEditedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, props.data) + break + } + + case AnalyticsEventNames.CURATE_OPERATION_APPLIED: { + const props = CurateOperationAppliedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, props.data) + break + } + + case AnalyticsEventNames.CURATE_RUN_COMPLETED: { + const props = CurateRunCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CURATE_RUN_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.DAEMON_RESET_EXECUTED: { + const props = DaemonResetExecutedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.DAEMON_RESET_EXECUTED, props.data) + break + } + + case AnalyticsEventNames.DAEMON_START: { + const props = DaemonStartSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.DAEMON_START) + break + } + + case AnalyticsEventNames.HUB_PACKAGE_INSTALLED: { + const props = HubPackageInstalledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.HUB_PACKAGE_INSTALLED, props.data) + break + } + + case AnalyticsEventNames.HUB_REGISTRY_ADDED: { + const props = HubRegistryAddedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.HUB_REGISTRY_ADDED, props.data) + break + } + + case AnalyticsEventNames.HUB_REGISTRY_REMOVED: { + const props = HubRegistryRemovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.HUB_REGISTRY_REMOVED, props.data) + break + } + + case AnalyticsEventNames.MCP_SESSION_ENDED: { + const props = McpSessionEndedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MCP_SESSION_ENDED, props.data) + break + } + + case AnalyticsEventNames.MCP_SESSION_START: { + const props = McpSessionStartSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MCP_SESSION_START, props.data) + break + } + + case AnalyticsEventNames.MCP_TOOL_CALLED: { + const props = McpToolCalledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MCP_TOOL_CALLED, props.data) + break + } + + case AnalyticsEventNames.MIGRATE_RUN: { + const props = MigrateRunSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MIGRATE_RUN, props.data) + break + } + + case AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED: { + const props = OnboardingAutoSetupStartedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED, props.data) + break + } + + case AnalyticsEventNames.ONBOARDING_COMPLETED: { + const props = OnboardingCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.ONBOARDING_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.QUERY_COMPLETED: { + const props = QueryCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.QUERY_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.REVIEW_APPROVED: { + const props = ReviewApprovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.REVIEW_APPROVED, props.data) + break + } + + case AnalyticsEventNames.REVIEW_REJECTED: { + const props = ReviewRejectedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.REVIEW_REJECTED, props.data) + break + } + + case AnalyticsEventNames.REVIEW_TOGGLED: { + const props = ReviewToggledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.REVIEW_TOGGLED, props.data) + break + } + + case AnalyticsEventNames.SETTING_CHANGED: { + const props = SettingChangedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SETTING_CHANGED, props.data) + break + } + + case AnalyticsEventNames.SETTING_RESET: { + const props = SettingResetSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SETTING_RESET, props.data) + break + } + + case AnalyticsEventNames.SOURCE_ADDED: { + const props = SourceAddedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SOURCE_ADDED, props.data) + break + } + + case AnalyticsEventNames.SOURCE_REMOVED: { + const props = SourceRemovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SOURCE_REMOVED, props.data) + break + } + + case AnalyticsEventNames.SPACE_SWITCHED: { + const props = SpaceSwitchedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SPACE_SWITCHED, props.data) + break + } + + case AnalyticsEventNames.TASK_COMPLETED: { + const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.TASK_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.TASK_CREATED: { + const props = TaskCreatedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.TASK_CREATED, props.data) + break + } + + case AnalyticsEventNames.TASK_FAILED: { + const props = TaskFailedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.TASK_FAILED, props.data) + break + } + + case AnalyticsEventNames.VC_BRANCHED: { + const props = VcBranchedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_BRANCHED, props.data) + break + } + + case AnalyticsEventNames.VC_CHECKED_OUT: { + const props = VcCheckedOutSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_CHECKED_OUT, props.data) + break + } + + case AnalyticsEventNames.VC_CLONED: { + const props = VcClonedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_CLONED, props.data) + break + } + + case AnalyticsEventNames.VC_COMMIT: { + const props = VcCommitSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_COMMIT, props.data) + break + } + + case AnalyticsEventNames.VC_DISCARDED: { + const props = VcDiscardedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_DISCARDED, props.data) + break + } + + case AnalyticsEventNames.VC_FETCHED: { + const props = VcFetchedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_FETCHED, props.data) + break + } + + case AnalyticsEventNames.VC_INIT: { + const props = VcInitSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_INIT, props.data) + break + } + + case AnalyticsEventNames.VC_MERGED: { + const props = VcMergedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_MERGED, props.data) + break + } + + case AnalyticsEventNames.VC_PULLED: { + const props = VcPulledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_PULLED, props.data) + break + } + + case AnalyticsEventNames.VC_PUSHED: { + const props = VcPushedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_PUSHED, props.data) + break + } + + case AnalyticsEventNames.VC_REMOTE_CHANGED: { + const props = VcRemoteChangedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_REMOTE_CHANGED, props.data) + break + } + + case AnalyticsEventNames.VC_RESET_EXECUTED: { + const props = VcResetExecutedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_RESET_EXECUTED, props.data) + break + } + + case AnalyticsEventNames.WEBUI_SESSION_ENDED: { + const props = WebuiSessionEndedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_ENDED, props.data) + break + } + + case AnalyticsEventNames.WEBUI_SESSION_STARTED: { + const props = WebuiSessionStartedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_STARTED, props.data) + break + } + + case AnalyticsEventNames.WORKTREE_ADDED: { + const props = WorktreeAddedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WORKTREE_ADDED, props.data) + break + } + + case AnalyticsEventNames.WORKTREE_REMOVED: { + const props = WorktreeRemovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WORKTREE_REMOVED, props.data) + break + } + // No default — `event` is narrowed to AnalyticsEventName by isAnalyticsEventName(). + } + } +} diff --git a/src/server/infra/transport/handlers/analytics-list-handler.ts b/src/server/infra/transport/handlers/analytics-list-handler.ts new file mode 100644 index 000000000..b0178997a --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-list-handler.ts @@ -0,0 +1,60 @@ +import type {IJsonlAnalyticsStore} from '../../../core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {redactRecord} from '../../../../shared/analytics/forbidden-field-names.js' +import { + AnalyticsEvents, + type AnalyticsListRequest, + AnalyticsListRequestSchema, + type AnalyticsListResponse, +} from '../../../../shared/transport/events/analytics-events.js' + +export interface AnalyticsListHandlerDeps { + jsonlStore: IJsonlAnalyticsStore + transport: ITransportServer +} + +const EMPTY_RESPONSE: AnalyticsListResponse = {rows: [], total: 0} + +/** + * Daemon-side handler for `analytics:list` (M11.2). Validates the + * inbound request against M11.1's Zod schema, delegates to + * `JsonlAnalyticsStore.list`, applies defense-in-depth property + * redaction (drops keys in `FORBIDDEN_FIELD_NAMES`), and returns + * `{rows, total}`. + * + * Defensive failure mode mirrors the existing `AnalyticsHandler`: + * malformed input or any throw from the store yields + * `{rows: [], total: 0}`. Analytics queries MUST NEVER crash the + * webui requester. + * + * Identity is intentionally NOT redacted — see `redactRecord` for the + * rationale (the four identity fields are super-properties, not + * event-specific content). + */ +export class AnalyticsListHandler { + private readonly jsonlStore: IJsonlAnalyticsStore + private readonly transport: ITransportServer + + public constructor(deps: AnalyticsListHandlerDeps) { + this.jsonlStore = deps.jsonlStore + this.transport = deps.transport + } + + public setup(): void { + this.transport.onRequest( + AnalyticsEvents.LIST, + async (data: unknown): Promise => { + const parsed = AnalyticsListRequestSchema.safeParse(data) + if (!parsed.success) return EMPTY_RESPONSE + + try { + const {rows, total} = await this.jsonlStore.list(parsed.data) + return {rows: rows.map((r) => redactRecord(r)), total} + } catch { + return EMPTY_RESPONSE + } + }, + ) + } +} diff --git a/src/server/infra/transport/handlers/analytics-status-handler.ts b/src/server/infra/transport/handlers/analytics-status-handler.ts new file mode 100644 index 000000000..d0b814a79 --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-status-handler.ts @@ -0,0 +1,44 @@ +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + AnalyticsEvents, + type AnalyticsStatusResponse, +} from '../../../../shared/transport/events/analytics-events.js' +import { + buildAnalyticsStatusSnapshot, + type BuildAnalyticsStatusSnapshotDeps, +} from '../../analytics/build-status-snapshot.js' + +// Re-export the reachability mapper for back-compat with existing tests +// and any external consumer that depended on its prior location. +export {consecutiveFailuresToReachabilityState, type ReachabilityState} from '../../analytics/build-status-snapshot.js' + +export interface AnalyticsStatusHandlerDeps extends BuildAnalyticsStatusSnapshotDeps { + readonly transport: ITransportServer +} + +/** + * Composes the `analytics:status` wire response for `brv analytics + * status` (M4.6). Delegates the actual snapshot composition to the + * shared `buildAnalyticsStatusSnapshot` builder so the legacy transport + * event and the new M16.3 settings handler share the same implementation. + */ +export class AnalyticsStatusHandler { + private readonly deps: AnalyticsStatusHandlerDeps + + public constructor(deps: AnalyticsStatusHandlerDeps) { + this.deps = deps + } + + public setup(): void { + this.deps.transport.onRequest(AnalyticsEvents.STATUS, async () => this.compose()) + } + + /** + * Compose the wire payload. Visible for tests; production caller is + * the transport handler registered in `setup()`. + */ + private async compose(): Promise { + return buildAnalyticsStatusSnapshot(this.deps) + } +} diff --git a/src/server/infra/transport/handlers/auth-handler.ts b/src/server/infra/transport/handlers/auth-handler.ts index b559ae6a6..952340f43 100644 --- a/src/server/infra/transport/handlers/auth-handler.ts +++ b/src/server/infra/transport/handlers/auth-handler.ts @@ -1,5 +1,8 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {UserDTO} from '../../../../shared/transport/types/dto.js' import type {User} from '../../../core/domain/entities/user.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IAuthService} from '../../../core/interfaces/auth/i-auth-service.js' import type {ICallbackHandler} from '../../../core/interfaces/auth/i-callback-handler.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' @@ -7,10 +10,12 @@ import type {IProviderConfigStore} from '../../../core/interfaces/i-provider-con import type {IBrowserLauncher} from '../../../core/interfaces/services/i-browser-launcher.js' import type {IUserService} from '../../../core/interfaces/services/i-user-service.js' import type {IAuthStateStore} from '../../../core/interfaces/state/i-auth-state-store.js' +import type {IGlobalConfigRotator} from '../../../core/interfaces/storage/i-global-config-rotator.js' import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {ProjectPathResolver} from './handler-types.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { AuthEvents, type AuthGetStateRequest, @@ -45,10 +50,25 @@ function toUserDTO(user: User): UserDTO { } export interface AuthHandlerDeps { + /** + * Optional. When provided, the handler emits `auth_login` / + * `auth_logout` analytics events on identity transitions. Optional so + * legacy construction (and unit tests that don't care about analytics) + * doesn't need to thread the dep through. Wired in `feature-handlers.ts`. + */ + analyticsClient?: IAnalyticsClient authService: IAuthService authStateStore: IAuthStateStore browserLauncher: IBrowserLauncher callbackHandler: ICallbackHandler + /** + * Optional. When provided, the handler rotates the global `device_id` + * on user-initiated identity transitions: explicit logout (if previously + * authenticated), account-switch on login (userA → userB), and + * refresh-failure sign-out. Optional so existing test harnesses don't + * have to thread the dep through. Wired in `feature-handlers.ts`. + */ + globalConfigRotator?: IGlobalConfigRotator projectConfigStore: IProjectConfigStore providerConfigStore: IProviderConfigStore resolveProjectPath: ProjectPathResolver @@ -62,10 +82,12 @@ export interface AuthHandlerDeps { * Business logic for authentication — no terminal/UI calls. */ export class AuthHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly authService: IAuthService private readonly authStateStore: IAuthStateStore private readonly browserLauncher: IBrowserLauncher private readonly callbackHandler: ICallbackHandler + private readonly globalConfigRotator: IGlobalConfigRotator | undefined private readonly projectConfigStore: IProjectConfigStore private readonly providerConfigStore: IProviderConfigStore private readonly resolveProjectPath: ProjectPathResolver @@ -74,10 +96,12 @@ export class AuthHandler { private readonly userService: IUserService constructor(deps: AuthHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.authService = deps.authService this.authStateStore = deps.authStateStore this.browserLauncher = deps.browserLauncher this.callbackHandler = deps.callbackHandler + this.globalConfigRotator = deps.globalConfigRotator this.projectConfigStore = deps.projectConfigStore this.providerConfigStore = deps.providerConfigStore this.resolveProjectPath = deps.resolveProjectPath @@ -136,6 +160,20 @@ export class AuthHandler { } } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from + * `analytics-hook.ts` so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Auth] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async processLoginCallback( authContext: {authUrl: string; state: string}, redirectUri: string, @@ -144,6 +182,16 @@ export class AuthHandler { const {code} = await this.callbackHandler.waitForCallback(authContext.state, 5 * 60 * 1000) const tokenData = await this.authService.exchangeCodeForToken(code, authContext, redirectUri) const user = await this.userService.getCurrentUser(tokenData.sessionKey) + + // Snapshot the previous live identity BEFORE save — drives the + // account-switch rotation rule below. Expired previous tokens do not + // count (the device was not actively claimed at switch time). + // safeLoadToken treats a read failure as "no previous identity": a + // transient token-store error must NOT discard a freshly-exchanged + // OAuth token. + const previousToken = await this.safeLoadToken() + const previousUserId = previousToken?.isValid() ? previousToken.userId : undefined + const authToken = new AuthToken({ accessToken: tokenData.accessToken, expiresAt: tokenData.expiresAt, @@ -162,6 +210,19 @@ export class AuthHandler { // new token without waiting for the next 5-second poll cycle. await this.authStateStore.loadToken() + // Emit AFTER loadToken so the per-event identity resolver stamps + // the row with the new authenticated user_id (the Mixpanel + // forwarder's alias path keys off `{name: auth_login, outcome: + // success}`). + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) + + // Rotate AFTER emit so this auth_login row carries the OLD device_id + // — the switch is attributed to the departing user's history. Only + // rotates on true account switch (live previous identity ≠ new). + if (previousUserId !== undefined && previousUserId !== user.id) { + await this.safeRotateDeviceId() + } + this.transport.broadcast(AuthEvents.LOGIN_COMPLETED, { success: true, user: toUserDTO(user), @@ -172,6 +233,12 @@ export class AuthHandler { user: toUserDTO(user), }) } catch (error) { + // Emit the failure terminal so the funnel sees both halves. + // Identity is still anonymous (token never committed). `failure_kind` + // is a coarse tag — never leak `error.message` here (would risk PII). + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {failure_kind: 'oauth_flow', outcome: 'failure'}) + this.transport.broadcast(AuthEvents.LOGIN_COMPLETED, { error: getErrorMessage(error), success: false, @@ -181,6 +248,46 @@ export class AuthHandler { } } + /** + * Reads the stored token, swallowing any error to `undefined`. Used by + * the auth RPC handlers to snapshot the pre-transition identity for + * rotation decisions; a transient read failure here must NOT abort the + * RPC (e.g. discard a freshly-exchanged OAuth token, or fail a logout). + * The default `FileTokenStore` already swallows internally, but custom + * stores might not — this keeps the handlers defensive. + */ + private async safeLoadToken(): Promise { + try { + return await this.tokenStore.load() + } catch (error) { + processLog(`[Auth] token load failed: ${getErrorMessage(error)}`) + // TRADE-OFF: returning undefined on a transient token-store error + // means the caller treats the prior identity as absent — so an + // account-switch login or logout on a flaky disk will silently + // skip the device_id rotation. We accept this because the + // alternative (rejecting the RPC) would discard a freshly-exchanged + // OAuth token or fail a user-initiated logout, which are worse. + // Disk errors are rare; the processLog above gives forensic + // breadcrumbs. + return undefined + } + } + + /** + * Rotates `device_id` without failing the calling RPC. Rotation MUST NOT + * block or fail an auth transition — it is post-hoc bookkeeping so the + * next analytics event ships under a fresh anonymous identity. + */ + private async safeRotateDeviceId(): Promise { + const rotator = this.globalConfigRotator + if (!rotator) return + try { + await rotator.rotateDeviceId() + } catch (error) { + processLog(`[Auth] device_id rotation failed: ${getErrorMessage(error)}`) + } + } + /** * Registers callbacks on AuthStateStore to broadcast auth events when * external changes are detected (CLI login, token expiry, token refresh). @@ -258,6 +365,15 @@ export class AuthHandler { async (data) => { try { const user = await this.userService.getCurrentUser(data.apiKey) + + // Snapshot the previous live identity BEFORE save — drives the + // account-switch rotation rule below. Expired tokens do not count + // (the device was not actively claimed at switch time). + // safeLoadToken swallows read failures to undefined so a + // transient token-store error does not fail the login RPC. + const previousToken = await this.safeLoadToken() + const previousUserId = previousToken?.isValid() ? previousToken.userId : undefined + const authToken = new AuthToken({ accessToken: 'unnecessary', expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days @@ -272,6 +388,15 @@ export class AuthHandler { await this.tokenStore.save(authToken) await this.authStateStore.loadToken() + // Emit AFTER loadToken (same identity-stamping rationale as the OAuth path). + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) + + // Rotate AFTER emit so this auth_login row carries the OLD device_id. + // Only rotates when a live previous identity is replaced by a different user. + if (previousUserId !== undefined && previousUserId !== user.id) { + await this.safeRotateDeviceId() + } + this.transport.broadcast(AuthEvents.STATE_CHANGED, { isAuthorized: true, user: toUserDTO(user), @@ -279,6 +404,12 @@ export class AuthHandler { return {success: true, userEmail: user.email} } catch (error) { + // Failure-path emit covers api-key auth failures (invalid key, + // network error, user fetch failure). Stays anonymous — no token was + // committed. + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {failure_kind: 'api_key', outcome: 'failure'}) + return {error: getErrorMessage(error), success: false} } }, @@ -287,13 +418,54 @@ export class AuthHandler { private setupLogout(): void { this.transport.onRequest(AuthEvents.LOGOUT, async () => { + // Snapshot identity BEFORE clearing — drives the "skip rotation when + // already anonymous" rule. An expired token is treated as anonymous + // (the device has not been actively claimed by a live session). + // safeLoadToken swallows read failures so a transient token-store + // error does not reject the logout RPC. + const previousToken = await this.safeLoadToken() + const wasAuthenticated = previousToken !== undefined && previousToken.isValid() + try { await this.tokenStore.clear() await this.disconnectByteRoverProvider() await this.authStateStore.loadToken() + + // Emit on the success terminal (single-emit guarantee — a + // success emit at the START would double-fire with the catch + // branch when a later step throws). By the time we reach here + // loadToken() has already flipped identity to anonymous, so the + // success row stamps `{device_id}` only. The OLD-identity events + // (any pending tracks under the logged-in user) ship separately: + // wire-analytics-auth-pre-transition.ts hooks `onBeforeAuthChange` + // and awaits `flush()` before loadToken commits the identity + // change, draining them under the logged-in identity. Downstream + // consumers join `auth_logout` rows back to the user via + // `device_id`. + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {outcome: 'success'}) + + // Rotate AFTER the emit so this auth_logout row still carries the + // OLD device_id (the one the departing user's history is keyed on). + // Subsequent track() calls will pick up the new id automatically — + // identity-resolver re-reads the config per event. + if (wasAuthenticated) { + await this.safeRotateDeviceId() + } + this.transport.broadcast(AuthEvents.STATE_CHANGED, {isAuthorized: false}) return {success: true} } catch { + // Failure-path emit covers token-clear / provider-disconnect / + // state-reload errors. Identity at trackAsync-resolve time may be + // logged-in (clear failed first) or anonymous (clear succeeded but a + // later step failed); both are valid for diagnostic purposes. + // `failure_kind` is a coarse tag — never raw `error.message`. + // Do NOT rotate on the failure branch: state is indeterminate (we + // may not actually be signed out) and rotating now could burn a + // device_id while the user is still effectively the previous identity. + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {failure_kind: 'logout_flow', outcome: 'failure'}) + return {success: false} } }) @@ -301,13 +473,59 @@ export class AuthHandler { private setupRefresh(): void { this.transport.onRequest(AuthEvents.REFRESH, async () => { + // safeLoadToken so a read failure short-circuits to {success:false} + // instead of rejecting the RPC. + const token = await this.safeLoadToken() + if (!token) { + return {success: false} + } + + // Narrow the sign-out catch to refreshToken() ONLY. A failure here + // means the auth server rejected the refresh (revoked, expired + // refresh token, network 401/403) — a definitive sign-out. Failures + // AFTER refresh succeeded (user-fetch 5xx, save() disk error) are + // post-refresh application failures: they should NOT burn a + // device_id rotation or be attributed as `refresh_failed` in the + // analytics funnel. + let refreshedTokenData try { - const token = await this.tokenStore.load() - if (!token) { - return {success: false} + refreshedTokenData = await this.authService.refreshToken(token.refreshToken) + } catch (error) { + processLog(`[Auth] refreshToken exchange failed: ${getErrorMessage(error)}`) + // Sign-out side effects mirror the logout success branch (clear → + // disconnect → reload → emit → rotate → broadcast). Each wrapped + // so a cascading failure does not skip the rest. Returning + // {success:false} preserves the prior contract. + await this.tokenStore.clear().catch((clearError: unknown) => { + processLog(`[Auth] token clear failed during refresh sign-out: ${getErrorMessage(clearError)}`) + }) + await this.disconnectByteRoverProvider() + await this.authStateStore.loadToken().catch((loadError: unknown) => { + processLog(`[Auth] authStateStore reload failed during refresh sign-out: ${getErrorMessage(loadError)}`) + }) + + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {failure_kind: 'refresh_failed', outcome: 'failure'}) + + if (token.isValid()) { + // Only retire the device when the pre-refresh identity was live. + // An already-expired token observed by the refresh RPC is not an + // active claim on the device. + await this.safeRotateDeviceId() } - const refreshedTokenData = await this.authService.refreshToken(token.refreshToken) + // Explicit STATE_CHANGED broadcast (symmetric with the logout + // success branch). The onAuthChanged listener also broadcasts + // after loadToken transitions the cached token, but the explicit + // call here delivers synchronously before this RPC returns. + this.transport.broadcast(AuthEvents.STATE_CHANGED, {isAuthorized: false}) + return {success: false} + } + + // Refresh exchange succeeded. Anything below is a post-refresh + // application failure (user fetch, save, etc.) — return + // {success:false} silently, do NOT trigger sign-out semantics. + try { const user = await this.userService.getCurrentUser(refreshedTokenData.sessionKey) const newToken = new AuthToken({ accessToken: refreshedTokenData.accessToken, @@ -329,7 +547,8 @@ export class AuthHandler { }) return {success: true} - } catch { + } catch (error) { + processLog(`[Auth] post-refresh application failed (token NOT applied, no sign-out): ${getErrorMessage(error)}`) return {success: false} } }) diff --git a/src/server/infra/transport/handlers/connectors-handler.ts b/src/server/infra/transport/handlers/connectors-handler.ts index 455cb29e1..d96daab68 100644 --- a/src/server/infra/transport/handlers/connectors-handler.ts +++ b/src/server/infra/transport/handlers/connectors-handler.ts @@ -1,8 +1,12 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {ConnectorDTO} from '../../../../shared/transport/types/dto.js' import type {ConnectorType} from '../../../../shared/types/connector-type.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IConnectorManager} from '../../../core/interfaces/connectors/i-connector-manager.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { ConnectorEvents, type ConnectorGetAgentConfigPathsRequest, @@ -14,10 +18,16 @@ import { } from '../../../../shared/transport/events/connector-events.js' import {isConnectorType} from '../../../../shared/types/connector-type.js' import {AGENT_CONNECTOR_CONFIG, isAgent} from '../../../core/domain/entities/agent.js' +import {processLog} from '../../../utils/process-logger.js' import {mapAgentsToDTOs} from './agent-dto-mapper.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface ConnectorsHandlerDeps { + /** + * Optional. When provided, the handler emits `connector_installed` + * analytics events at both terminals. + */ + analyticsClient?: IAnalyticsClient connectorManagerFactory: (projectRoot: string) => IConnectorManager resolveProjectPath: ProjectPathResolver transport: ITransportServer @@ -28,11 +38,13 @@ export interface ConnectorsHandlerDeps { * Business logic for connector management — no terminal/UI calls. */ export class ConnectorsHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly connectorManagerFactory: (projectRoot: string) => IConnectorManager private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: ConnectorsHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.connectorManagerFactory = deps.connectorManagerFactory this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport @@ -56,6 +68,22 @@ export class ConnectorsHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog( + `[Connectors] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + private handleGetAgentConfigPaths( data: ConnectorGetAgentConfigPathsRequest, clientId: string, @@ -80,18 +108,60 @@ export class ConnectorsHandler { private async handleInstall(data: ConnectorInstallRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const connectorManager = this.connectorManagerFactory(projectPath) + // Wire-side fields are always strings; coerce defensively so the + // emit doesn't carry undefined/null when the validation guards reject. + const agentTarget = String(data.agentId) + const connectorId = String(data.connectorType) if (!isAgent(data.agentId)) { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + // eslint-disable-next-line camelcase + failure_kind: 'invalid_agent', + outcome: 'failure', + }) return {message: `Unsupported agent: ${data.agentId}`, success: false} } if (!isConnectorType(data.connectorType)) { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + // eslint-disable-next-line camelcase + failure_kind: 'invalid_connector', + outcome: 'failure', + }) return {message: `Unsupported connector type: ${data.connectorType}`, success: false} } + const connectorManager = this.connectorManagerFactory(projectPath) const result = await connectorManager.switchConnector(data.agentId, data.connectorType) + if (result.success) { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + outcome: 'success', + }) + } else { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + // eslint-disable-next-line camelcase + failure_kind: 'install_failed', + outcome: 'failure', + }) + } + return { configPath: result.installResult.configPath, manualInstructions: result.installResult.manualInstructions, diff --git a/src/server/infra/transport/handlers/context-tree-handler.ts b/src/server/infra/transport/handlers/context-tree-handler.ts index b9a174c58..e5b545c4d 100644 --- a/src/server/infra/transport/handlers/context-tree-handler.ts +++ b/src/server/infra/transport/handlers/context-tree-handler.ts @@ -1,11 +1,15 @@ -import {mkdir, readdir, writeFile} from 'node:fs/promises' +import {mkdir, readdir, stat, writeFile} from 'node:fs/promises' import {dirname, join, relative} from 'node:path' +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IContextFileReader} from '../../../core/interfaces/context-tree/i-context-file-reader.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' import type {IGitService} from '../../../core/interfaces/services/i-git-service.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { ContextTreeEvents, type ContextTreeGetFileMetadataRequest, @@ -21,6 +25,8 @@ import { type ContextTreeUpdateFileResponse, } from '../../../../shared/transport/events/context-tree-events.js' import {ARCHIVE_DIR, DEFAULT_BRANCH, README_FILE, SNAPSHOT_FILE} from '../../../constants.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {isExcludedFromSync} from '../../context-tree/derived-artifact.js' import {toUnixPath} from '../../context-tree/path-utils.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' @@ -29,6 +35,7 @@ const DEFAULT_HISTORY_LIMIT = 10 const SCAN_SKIP_NAMES = new Set(['.git', '.gitignore', ARCHIVE_DIR, SNAPSHOT_FILE]) export interface ContextTreeHandlerDeps { + analyticsClient?: IAnalyticsClient contextFileReader: IContextFileReader contextTreeService: IContextTreeService gitService: Pick @@ -37,6 +44,7 @@ export interface ContextTreeHandlerDeps { } export class ContextTreeHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly contextFileReader: IContextFileReader private readonly contextTreeService: IContextTreeService private readonly gitService: Pick @@ -44,6 +52,7 @@ export class ContextTreeHandler { private readonly transport: ITransportServer constructor(deps: ContextTreeHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.contextFileReader = deps.contextFileReader this.contextTreeService = deps.contextTreeService this.gitService = deps.gitService @@ -78,6 +87,20 @@ export class ContextTreeHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[ContextTree] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleGetFile( data: ContextTreeGetFileRequest, clientId: string, @@ -187,16 +210,46 @@ export class ContextTreeHandler { const contextTreeDir = this.contextTreeService.resolvePath(projectPath) const fullPath = join(contextTreeDir, data.path) - // Guard against path traversal - const resolved = relative(contextTreeDir, fullPath) - if (resolved.startsWith('..') || resolved.startsWith('/')) { - throw new Error('Path traversal not allowed') + try { + // Guard against path traversal + const resolved = relative(contextTreeDir, fullPath) + if (resolved.startsWith('..') || resolved.startsWith('/')) { + throw new Error('Path traversal not allowed') + } + + // Only stat the file when analytics is on — needed for byte_delta. + // Skipping when analyticsClient is undefined keeps this handler's + // disk-I/O profile identical to the original implementation. + const baselineSize = this.analyticsClient + ? await stat(fullPath) + .then((s) => s.size) + .catch(() => 0) + : 0 + + await mkdir(dirname(fullPath), {recursive: true}) + await writeFile(fullPath, data.content, 'utf8') + + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, { + byte_delta: Buffer.byteLength(data.content, 'utf8') - baselineSize, + file_relative_path_hash: hashProjectPath(data.path), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + + return {success: true} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, { + failure_kind: classifyUpdateFileFailure(error), + file_relative_path_hash: hashProjectPath(data.path), + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + throw error } - - await mkdir(dirname(fullPath), {recursive: true}) - await writeFile(fullPath, data.content, 'utf8') - - return {success: true} } /** Resolves project path from explicit request field or client registration fallback. */ @@ -255,3 +308,15 @@ export class ContextTreeHandler { }) } } + +function classifyUpdateFileFailure(error: unknown): string { + // A path-traversal rejection is a rejected-input/security signal, not a + // write conflict — classify it accordingly for the analytics funnel. + if (error instanceof Error && error.message.includes('traversal')) return 'invalid_path' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'fs_access' + } + + return 'unknown' +} diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts new file mode 100644 index 000000000..5ec19e100 --- /dev/null +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -0,0 +1,282 @@ +import {randomUUID} from 'node:crypto' + +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {IGlobalConfigRotator} from '../../../core/interfaces/storage/i-global-config-rotator.js' +import type {IGlobalConfigStore} from '../../../core/interfaces/storage/i-global-config-store.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, + type GlobalConfigSetAnalyticsRequest, + type GlobalConfigSetAnalyticsResponse, +} from '../../../../shared/transport/events/global-config-events.js' +import {GLOBAL_CONFIG_VERSION} from '../../../constants.js' +import {GlobalConfig} from '../../../core/domain/entities/global-config.js' +import {processLog} from '../../../utils/process-logger.js' + +export interface GlobalConfigHandlerDeps { + /** + * M4.4: optional analytics client used to cancel any in-flight HTTP + * send when `brv settings set analytics.share false` flips the flag. + * Disable does NOT drop the queue or clear JSONL — those stay so a + * future re-enable ships the backlog. Optional for back-compat with + * test harnesses that don't construct a real analytics client. + */ + analyticsClient?: IAnalyticsClient + globalConfigStore: IGlobalConfigStore + transport: ITransportServer +} + +/** + * Handles globalConfig:get and globalConfig:setAnalytics events. + * Re-reads the file every call (no in-memory cache for transport responses) + * so the daemon always reflects the latest on-disk state. + * + * `read()` is a pure read: when no config file exists yet, returns + * synthetic defaults (analytics: false, empty deviceId). Persistence is + * deferred to the first SET_ANALYTICS write path, where deviceId is + * generated and stored atomically — keeping read() pure avoids a race + * where two concurrent GETs each create+write a different deviceId. + * + * SET_ANALYTICS is idempotent: if the requested state matches current + * state, the file is not rewritten. + * + * Maintains a SYNC in-process cache of the analytics flag for consumers + * that need a synchronous getter (AnalyticsClient.isEnabled). The cache + * is populated by an explicit `await refreshCache()` (the daemon + * bootstrap awaits this once before constructing AnalyticsClient) and + * refreshed after every successful SET_ANALYTICS write or read of an + * existing on-disk config. Transport responses still read fresh from + * disk — the cache is purely an in-process bridge for sync consumers. + */ +export class GlobalConfigHandler implements IGlobalConfigRotator { + private analyticsClient: IAnalyticsClient | undefined + private cachedAnalytics: boolean | undefined + private readonly globalConfigStore: IGlobalConfigStore + private readonly transport: ITransportServer + // Serializes SET_ANALYTICS write paths. Two concurrent enables on a + // fresh install would otherwise both observe `existing=undefined`, both + // generate a different `randomUUID()` deviceId, and both write — the + // persisted deviceId would be whichever lost the write race. Chain + // pattern mirrors `JsonlAnalyticsStore.writeChain`. + private writeChain: Promise = Promise.resolve() + + constructor(deps: GlobalConfigHandlerDeps) { + this.analyticsClient = deps.analyticsClient + this.globalConfigStore = deps.globalConfigStore + this.transport = deps.transport + } + + /** + * Synchronous getter for the cached analytics flag. Used by daemon-side + * consumers (M2.5's AnalyticsClient) that cannot await the async store. + * + * THROWS if called before `refreshCache()` has resolved (or before any + * GET/SET handler has populated the cache). A silent default-false here + * caused a real product-correctness bug during M2.5 development — + * `daemon_start` would observe analytics=false even when the user had it + * enabled on disk. Failing loud forces the lifecycle requirement to + * surface during bootstrap rather than silently miscount. + */ + getCachedAnalytics(): boolean { + if (this.cachedAnalytics === undefined) { + throw new Error( + 'GlobalConfigHandler.getCachedAnalytics() called before refreshCache() resolved. ' + + 'Daemon bootstrap must `await handler.refreshCache()` before constructing any consumer that reads the cache.', + ) + } + + return this.cachedAnalytics + } + + /** + * Public async read of the persisted analytics flag. Surfaced for + * the SettingsHandler facade so `brv settings get analytics.share` + * resolves through the SAME `globalConfigStore.read()` path that + * `globalConfig:get` uses. Returns the on-disk value (or `false` + * when no config file exists). + */ + async getCurrentAnalytics(): Promise { + const response = await this.read() + return response.analytics + } + + /** + * Synchronously refreshes the cached analytics flag from disk. Daemon + * bootstrap awaits this once before constructing AnalyticsClient so + * the very first `track()` (e.g. `daemon_start`) sees the correct + * enabled state. Subsequent updates happen automatically inside + * SET_ANALYTICS without any caller involvement. + */ + async refreshCache(): Promise { + try { + const existing = await this.globalConfigStore.read() + this.cachedAnalytics = existing?.analytics ?? false + } catch { + // Fail-safe: explicitly set the cache to false on any read failure so + // a subsequent getCachedAnalytics() does NOT throw. Production + // FileGlobalConfigStore catches its own errors and never throws, but + // we MUST handle a hypothetical store that does — otherwise a + // bootstrap read failure would crash the daemon when track() runs. + this.cachedAnalytics = false + } + } + + /** + * Rotates the on-disk `deviceId` with a fresh UUID. Preserves the + * analytics flag + version. No-ops if no config file exists (analytics + * never enabled → nothing to retire). Goes through the same + * `writeChain` as `setAnalytics` so a concurrent enable/disable cannot + * race the rotation onto a stale read. + * + * Does NOT emit an analytics event — rotation is implied by the next + * tracked event carrying the new `device_id`. + * + * Does NOT touch `cachedAnalytics` — the flag is unchanged. + * + * @returns `true` if a rotation occurred, `false` on the no-config no-op. + */ + public async rotateDeviceId(): Promise { + const next = this.writeChain.then(async () => this.doRotateDeviceId()) + this.writeChain = next.then( + () => {}, + () => {}, + ) + return next + } + + /** + * M4.4: late-bound analytics client setter. The composition root + * constructs `GlobalConfigHandler` BEFORE `AnalyticsClient` exists + * (the cached-analytics flag must be populated before the client + * reads it). This setter closes that loop: once the client is built, + * the daemon wires it in so disable-time `abort()` works. + * + * Calling this more than once silently replaces the reference. Idempotent. + */ + setAnalyticsClient(client: IAnalyticsClient): void { + this.analyticsClient = client + } + + /** + * Public write of the analytics flag. Surfaced for the SettingsHandler + * facade so `brv settings set analytics.share ` goes through + * the SAME write path as `globalConfig:setAnalytics` — concurrent-safe + * via `writeChain`, refreshes the cache, emits `analytics_disabled`, + * triggers the abort-on-disable on the analytics client. + */ + async setAnalyticsValue(value: boolean): Promise { + return this.setAnalytics(value) + } + + setup(): void { + this.transport.onRequest(GlobalConfigEvents.GET, async () => this.read()) + this.transport.onRequest( + GlobalConfigEvents.SET_ANALYTICS, + async (data) => this.setAnalytics(data.analytics), + ) + } + + private async doRotateDeviceId(): Promise { + const existing = await this.globalConfigStore.read() + if (!existing) { + return false + } + + const updated = existing.withDeviceId(randomUUID()) + await this.globalConfigStore.write(updated) + return true + } + + private async doSetAnalytics(analytics: boolean): Promise { + const existing = await this.globalConfigStore.read() + const previous = existing?.analytics ?? false + + // Idempotent fast path: short-circuit before generating a deviceId. + // If existing is undefined and the requested value matches the default + // (false), no file is created — the next GET will seed. + if (previous === analytics) { + this.cachedAnalytics = previous + return {current: previous, previous} + } + + const current = existing ?? GlobalConfig.create(randomUUID()) + const updated = current.withAnalytics(analytics) + await this.globalConfigStore.write(updated) + + // Emit BEFORE flipping `cachedAnalytics` so AnalyticsClient.isEnabled + // (which reads the cache) still resolves true at track time and the row + // enters the queue. After this line the cache flips to false and any + // subsequent track() is no-op'd. analytics_enabled is intentionally NOT + // tracked (the user has not consented at receive time when enabling). + if (previous && !analytics) { + try { + this.analyticsClient?.track(AnalyticsEventNames.ANALYTICS_DISABLED) + } catch (error) { + processLog( + `[GlobalConfig] analytics_disabled track failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + // Cache is in-process-authoritative — we trust the value just written. + // Cross-process changes (another daemon writing the same file, manual + // edits) are NOT observable until the next daemon restart. The + // single-daemon model makes this safe today. + this.cachedAnalytics = updated.analytics + + // M4.4: on enable → disable, abort any in-flight analytics HTTP so + // the daemon doesn't half-ship a batch across the boundary. Disable + // does NOT drop the queue or clear JSONL — the backlog persists and + // ships on re-enable. abort() errors are swallowed: a failed cancel + // MUST NOT block the config write the user explicitly requested. + if (previous && !analytics) { + try { + this.analyticsClient?.abort() + } catch { + /* swallow — analytics MUST NOT block config writes */ + } + } + + return {current: updated.analytics, previous} + } + + private async read(): Promise { + const existing = await this.globalConfigStore.read() + if (existing) { + this.cachedAnalytics = existing.analytics + return { + analytics: existing.analytics, + deviceId: existing.deviceId, + version: existing.version, + } + } + + // No config on disk yet — return synthetic defaults. Persistence is + // deferred to the first SET_ANALYTICS write path, where deviceId is + // generated and stored atomically. Keeping read() side-effect-free + // closes the race where two concurrent GETs would each create+write a + // different deviceId. Cache population for synchronous consumers + // happens via refreshCache() at daemon bootstrap, not here. + return { + analytics: false, + deviceId: '', + version: GLOBAL_CONFIG_VERSION, + } + } + + private async setAnalytics(analytics: boolean): Promise { + // Serialize against any in-flight SET_ANALYTICS so concurrent enables + // on a fresh install do not both seed independent deviceIds. + const next = this.writeChain.then(async () => this.doSetAnalytics(analytics)) + // Chain itself swallows errors so a failure in one call does NOT + // reject all subsequent calls; the awaiter still observes its own error. + this.writeChain = next.then( + () => {}, + () => {}, + ) + return next + } +} diff --git a/src/server/infra/transport/handlers/hub-handler.ts b/src/server/infra/transport/handlers/hub-handler.ts index e75fe3268..238197450 100644 --- a/src/server/infra/transport/handlers/hub-handler.ts +++ b/src/server/infra/transport/handlers/hub-handler.ts @@ -1,5 +1,8 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {AuthScheme} from '../../../../shared/transport/types/auth-scheme.js' import type {HubEntryDTO} from '../../../../shared/transport/types/dto.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {HubInstallAuthParams, IHubInstallService} from '../../../core/interfaces/hub/i-hub-install-service.js' import type {IHubKeychainStore} from '../../../core/interfaces/hub/i-hub-keychain-store.js' import type {IHubRegistryConfigStore} from '../../../core/interfaces/hub/i-hub-registry-config-store.js' @@ -7,6 +10,7 @@ import type {IHubRegistryService} from '../../../core/interfaces/hub/i-hub-regis import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {ProjectPathResolver} from './handler-types.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { HubEvents, type HubInstallRequest, @@ -20,6 +24,7 @@ import { type HubRegistryRemoveResponse, } from '../../../../shared/transport/events/hub-events.js' import {type Agent, isAgent} from '../../../core/domain/entities/agent.js' +import {processLog} from '../../../utils/process-logger.js' import {SKILL_CONNECTOR_CONFIGS} from '../../connectors/skill/skill-connector-config.js' import {CompositeHubRegistryService} from '../../hub/composite-hub-registry-service.js' import {HubRegistryService} from '../../hub/hub-registry-service.js' @@ -29,6 +34,11 @@ const OFFICIAL_REGISTRY_NAME = 'official' const RESERVED_REGISTRY_NAMES = new Set(['brv', 'byterover', 'campfire', 'campfirein', 'official']) export interface HubHandlerDeps { + /** + * Optional. When provided, the handler emits `hub_package_installed` / + * `hub_registry_added` / `hub_registry_removed` analytics events. + */ + analyticsClient?: IAnalyticsClient hubInstallService: IHubInstallService hubKeychainStore: IHubKeychainStore hubRegistryConfigStore: IHubRegistryConfigStore @@ -39,6 +49,7 @@ export interface HubHandlerDeps { } export class HubHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly hubInstallService: IHubInstallService private readonly hubKeychainStore: IHubKeychainStore private readonly hubRegistryConfigStore: IHubRegistryConfigStore @@ -49,6 +60,7 @@ export class HubHandler { private readonly transport: ITransportServer constructor(deps: HubHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.hubInstallService = deps.hubInstallService this.hubKeychainStore = deps.hubKeychainStore this.hubRegistryConfigStore = deps.hubRegistryConfigStore @@ -97,9 +109,35 @@ export class HubHandler { return config && !config.projectPath ? 'global' : 'project' } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Hub] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private emitInstallFailure(packageIdentifier: string, failureKind: string): void { + this.emitAnalytics(AnalyticsEventNames.HUB_PACKAGE_INSTALLED, { + // eslint-disable-next-line camelcase + failure_kind: failureKind, + outcome: 'failure', + // eslint-disable-next-line camelcase + package_identifier: packageIdentifier, + }) + } + private async handleInstall(data: HubInstallRequest, clientId: string): Promise { + const packageIdentifier = data.entryId const agent = data.agent && isAgent(data.agent) ? data.agent : undefined if (data.agent && !agent) { + this.emitInstallFailure(packageIdentifier, 'invalid_agent') return {installedFiles: [], installedPath: '', message: `Invalid agent: ${data.agent}`, success: false} } @@ -117,17 +155,19 @@ export class HubHandler { switch (matches.length) { case 0: { + this.emitInstallFailure(packageIdentifier, 'resolve') return {installedFiles: [], installedPath: '', message: `Entry not found: ${data.entryId}`, success: false} } case 1: { - // Single match: proceed with install + // Single match: proceed with install. performInstall emits success/failure. return this.performInstall({agent, entry: matches[0], projectPath, scope}) } default: { // Multiple matches: detect duplicates const registryNames = matches.map((m) => m.registry ?? 'unknown').join(', ') + this.emitInstallFailure(packageIdentifier, 'resolve') return { installedFiles: [], installedPath: '', @@ -145,8 +185,16 @@ export class HubHandler { } private async handleRegistryAdd(data: HubRegistryAddRequest): Promise { + const registryKind = data.name try { if (RESERVED_REGISTRY_NAMES.has(data.name.toLowerCase())) { + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_ADDED, { + // eslint-disable-next-line camelcase + failure_kind: 'validation', + outcome: 'failure', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) return {message: `Registry name '${data.name}' is reserved`, success: false} } @@ -176,8 +224,23 @@ export class HubHandler { await this.rebuildRegistryService() + // `is_default` omitted — request shape doesn't carry it; schema marks + // the field optional precisely for this reason. + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_ADDED, { + outcome: 'success', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) + return {message: `Registry '${data.name}' added successfully`, success: true} } catch (error) { + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_ADDED, { + // eslint-disable-next-line camelcase + failure_kind: 'config_write', + outcome: 'failure', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) return { message: `Failed to add registry: ${error instanceof Error ? error.message : 'Unknown error'}`, success: false, @@ -229,14 +292,28 @@ export class HubHandler { } private async handleRegistryRemove(data: HubRegistryRemoveRequest): Promise { + const registryKind = data.name try { await this.hubRegistryConfigStore.removeRegistry(data.name) await this.hubKeychainStore.deleteToken(data.name) await this.rebuildRegistryService() + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_REMOVED, { + outcome: 'success', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) + return {message: `Registry '${data.name}' removed successfully`, success: true} } catch (error) { + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_REMOVED, { + // eslint-disable-next-line camelcase + failure_kind: 'config_write', + outcome: 'failure', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) return { message: `Failed to remove registry: ${error instanceof Error ? error.message : 'Unknown error'}`, success: false, @@ -266,6 +343,13 @@ export class HubHandler { const result = await this.hubInstallService.install({agent, auth, entry, projectPath, scope}) const registryLabel = entry.registry ? ` [${entry.registry}]` : '' + + this.emitAnalytics(AnalyticsEventNames.HUB_PACKAGE_INSTALLED, { + outcome: 'success', + // eslint-disable-next-line camelcase + package_identifier: entry.id, + }) + return { installedFiles: result.installedFiles, installedPath: result.installedPath, @@ -273,6 +357,7 @@ export class HubHandler { success: true, } } catch (error) { + this.emitInstallFailure(entry.id, 'install_failed') return { installedFiles: [], installedPath: '', diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 417a423d2..c842b5192 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -1,3 +1,9 @@ +export {AnalyticsHandler} from './analytics-handler.js' +export type {AnalyticsHandlerDeps} from './analytics-handler.js' +export {AnalyticsListHandler} from './analytics-list-handler.js' +export type {AnalyticsListHandlerDeps} from './analytics-list-handler.js' +export {AnalyticsStatusHandler} from './analytics-status-handler.js' +export type {AnalyticsStatusHandlerDeps} from './analytics-status-handler.js' export {AuthHandler} from './auth-handler.js' export type {AuthHandlerDeps} from './auth-handler.js' export {BillingHandler} from './billing-handler.js' @@ -8,6 +14,8 @@ export {ConnectorsHandler} from './connectors-handler.js' export type {ConnectorsHandlerDeps} from './connectors-handler.js' export {ContextTreeHandler} from './context-tree-handler.js' export type {ContextTreeHandlerDeps} from './context-tree-handler.js' +export {GlobalConfigHandler} from './global-config-handler.js' +export type {GlobalConfigHandlerDeps} from './global-config-handler.js' export type {ProjectBroadcaster, ProjectPathResolver} from './handler-types.js' export {resolveRequiredProjectPath} from './handler-types.js' export {HubHandler} from './hub-handler.js' @@ -38,6 +46,8 @@ export {SpaceHandler} from './space-handler.js' export type {SpaceHandlerDeps} from './space-handler.js' export {StatusHandler} from './status-handler.js' export type {StatusHandlerDeps} from './status-handler.js' +export {SwarmHandler} from './swarm-handler.js' +export type {SwarmHandlerDeps} from './swarm-handler.js' export {TeamHandler} from './team-handler.js' export type {TeamHandlerDeps} from './team-handler.js' export {VcHandler} from './vc-handler.js' diff --git a/src/server/infra/transport/handlers/init-handler.ts b/src/server/infra/transport/handlers/init-handler.ts index f1452284e..b1ec7c459 100644 --- a/src/server/infra/transport/handlers/init-handler.ts +++ b/src/server/infra/transport/handlers/init-handler.ts @@ -1,3 +1,6 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IConnectorManager} from '../../../core/interfaces/connectors/i-connector-manager.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' @@ -9,6 +12,7 @@ import type {ITeamService} from '../../../core/interfaces/services/i-team-servic import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { InitEvents, type InitExecuteRequest, @@ -26,6 +30,8 @@ import {BrvConfig} from '../../../core/domain/entities/brv-config.js' import {NotAuthenticatedError, SpaceNotFoundError} from '../../../core/domain/errors/task-error.js' import {syncConfigToXdg} from '../../../utils/config-xdg-sync.js' import {getErrorMessage} from '../../../utils/error-helpers.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {ensureProjectInitialized} from '../../config/auto-init.js' import {mapAgentsToDTOs} from './agent-dto-mapper.js' import { @@ -36,6 +42,11 @@ import { } from './handler-types.js' export interface InitHandlerDeps { + /** + * Optional. When provided, the handler emits `brv_init` analytics + * events at both the success terminal and every catch branch. + */ + analyticsClient?: IAnalyticsClient broadcastToProject: ProjectBroadcaster cogitPullService: ICogitPullService connectorManagerFactory: (projectRoot: string) => IConnectorManager @@ -56,6 +67,7 @@ export interface InitHandlerDeps { * The TUI orchestrates the multi-step UX flow, calling granular events. */ export class InitHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly broadcastToProject: ProjectBroadcaster private readonly cogitPullService: ICogitPullService private readonly connectorManagerFactory: (projectRoot: string) => IConnectorManager @@ -70,6 +82,7 @@ export class InitHandler { private readonly transport: ITransportServer constructor(deps: InitHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.broadcastToProject = deps.broadcastToProject this.cogitPullService = deps.cogitPullService this.connectorManagerFactory = deps.connectorManagerFactory @@ -102,6 +115,20 @@ export class InitHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Init] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleExecute(data: InitExecuteRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) await guardAgainstGitVc({contextTreeService: this.contextTreeService, projectPath}) @@ -111,8 +138,11 @@ export class InitHandler { throw new NotAuthenticatedError() } - // Check for existing config - if ((await this.projectConfigStore.exists(projectPath)) && !data.force) { + // Naming-only refactor: capture the existing exists() result so the success + // emit below can include `had_existing_brv_dir` without changing call order + // or adding a second filesystem read. + const hadExistingBrvDir = await this.projectConfigStore.exists(projectPath) + if (hadExistingBrvDir && !data.force) { throw new Error('Project already initialized. Use force to re-initialize.') } @@ -188,6 +218,13 @@ export class InitHandler { success: true, }) + this.emitAnalytics(AnalyticsEventNames.BRV_INIT, { + // eslint-disable-next-line camelcase + had_existing_brv_dir: hadExistingBrvDir, + outcome: 'success', + // eslint-disable-next-line camelcase + project_path_hash: hashProjectPath(projectPath), + }) return {success: true} } @@ -236,9 +273,16 @@ export class InitHandler { private async handleLocalInit(data: InitLocalRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - - const exists = await this.projectConfigStore.exists(projectPath) - if (exists && !data.force) { + const hadExistingBrvDir = await this.projectConfigStore.exists(projectPath) + + if (hadExistingBrvDir && !data.force) { + this.emitAnalytics(AnalyticsEventNames.BRV_INIT, { + // eslint-disable-next-line camelcase + had_existing_brv_dir: true, + outcome: 'success', + // eslint-disable-next-line camelcase + project_path_hash: hashProjectPath(projectPath), + }) return {alreadyInitialized: true, success: true} } @@ -247,6 +291,13 @@ export class InitHandler { projectPath, ) + this.emitAnalytics(AnalyticsEventNames.BRV_INIT, { + // eslint-disable-next-line camelcase + had_existing_brv_dir: hadExistingBrvDir, + outcome: 'success', + // eslint-disable-next-line camelcase + project_path_hash: hashProjectPath(projectPath), + }) return {alreadyInitialized: false, success: true} } } diff --git a/src/server/infra/transport/handlers/migrate-handler.ts b/src/server/infra/transport/handlers/migrate-handler.ts index 0cef08c0c..65101885b 100644 --- a/src/server/infra/transport/handlers/migrate-handler.ts +++ b/src/server/infra/transport/handlers/migrate-handler.ts @@ -12,28 +12,36 @@ * VcHandler, PushHandler, etc. */ +/* eslint-disable camelcase */ import type { MigrateRollbackRequest, MigrateRollbackResponse, MigrateRunRequest, MigrateRunResponse, } from '../../../../shared/transport/events/migrate-events.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {type MigrateRunProps} from '../../../../shared/analytics/events/migrate-run.js' import {MigrateEvents} from '../../../../shared/transport/events/migrate-events.js' +import {processLog} from '../../../utils/process-logger.js' import {rollback, runMigration} from '../../migrate/orchestrator.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface MigrateHandlerDeps { + readonly analyticsClient?: IAnalyticsClient resolveProjectPath: ProjectPathResolver transport: ITransportServer } export class MigrateHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: MigrateHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport } @@ -49,12 +57,47 @@ export class MigrateHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from + * SettingsHandler so analytics failures never affect command outcomes. + */ + private emitMigrateRun(properties: MigrateRunProps): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(AnalyticsEventNames.MIGRATE_RUN, properties) + } catch (error) { + processLog( + `[Migrate] analytics track migrate_run failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + private async handleRollback( data: MigrateRollbackRequest, clientId: string, ): Promise { const projectRoot = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - return rollback({dryRun: data.dryRun, projectRoot}) + try { + const report = rollback({dryRun: data.dryRun, projectRoot}) + this.emitMigrateRun({ + deleted_html: report.deletedHtml.length, + dry_run: report.dryRun, + mode: 'rollback', + outcome: 'success', + preserved_html: report.preservedHtml.length, + restored: report.restored, + }) + return report + } catch (error) { + this.emitMigrateRun({ + dry_run: data.dryRun, + failure_kind: classifyMigrateFailure(error), + mode: 'rollback', + outcome: 'failure', + }) + throw error + } } private async handleRun( @@ -62,7 +105,36 @@ export class MigrateHandler { clientId: string, ): Promise { const projectRoot = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const report = runMigration({dryRun: data.dryRun, projectRoot}) - return {report} + try { + const report = runMigration({dryRun: data.dryRun, projectRoot}) + this.emitMigrateRun({ + archived: report.summary.archived, + dry_run: report.dryRun, + failed: report.summary.failed, + migrated: report.summary.migrated, + mode: 'forward', + outcome: 'success', + skipped: report.summary.skipped, + }) + return {report} + } catch (error) { + this.emitMigrateRun({ + dry_run: data.dryRun, + failure_kind: classifyMigrateFailure(error), + mode: 'forward', + outcome: 'failure', + }) + throw error + } + } +} + +function classifyMigrateFailure(error: unknown): string { + if (error instanceof Error) { + const msg = error.message + if (msg.startsWith('Migration already ran today')) return 'archive_exists' + if (msg.startsWith('No archive to roll back')) return 'no_archive' } + + return 'unknown' } diff --git a/src/server/infra/transport/handlers/reset-handler.ts b/src/server/infra/transport/handlers/reset-handler.ts index 96c27337e..6c11e2238 100644 --- a/src/server/infra/transport/handlers/reset-handler.ts +++ b/src/server/infra/transport/handlers/reset-handler.ts @@ -1,14 +1,20 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' import type {IContextTreeSnapshotService} from '../../../core/interfaces/context-tree/i-context-tree-snapshot-service.js' import type {ICurateLogStore} from '../../../core/interfaces/storage/i-curate-log-store.js' import type {IReviewBackupStore} from '../../../core/interfaces/storage/i-review-backup-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {ResetEvents, type ResetExecuteResponse} from '../../../../shared/transport/events/reset-events.js' -import {ContextTreeNotInitializedError} from '../../../core/domain/errors/task-error.js' +import {ContextTreeNotInitializedError, GitVcInitializedError} from '../../../core/domain/errors/task-error.js' +import {processLog} from '../../../utils/process-logger.js' import {guardAgainstGitVc, type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface ResetHandlerDeps { + analyticsClient?: IAnalyticsClient contextTreeService: IContextTreeService contextTreeSnapshotService: IContextTreeSnapshotService curateLogStoreFactory: (projectPath: string) => ICurateLogStore @@ -22,6 +28,7 @@ export interface ResetHandlerDeps { * Deletes and re-initializes the context tree — no terminal/UI calls. */ export class ResetHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly contextTreeService: IContextTreeService private readonly contextTreeSnapshotService: IContextTreeSnapshotService private readonly curateLogStoreFactory: (projectPath: string) => ICurateLogStore @@ -30,6 +37,7 @@ export class ResetHandler { private readonly transport: ITransportServer constructor(deps: ResetHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.contextTreeService = deps.contextTreeService this.contextTreeSnapshotService = deps.contextTreeSnapshotService this.curateLogStoreFactory = deps.curateLogStoreFactory @@ -65,32 +73,74 @@ export class ResetHandler { await Promise.all(updates.map((u) => store.batchUpdateOperationReviewStatus(u.id, u.pendingIndices))) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Reset] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleExecute(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - await guardAgainstGitVc({contextTreeService: this.contextTreeService, projectPath}) + try { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + await guardAgainstGitVc({contextTreeService: this.contextTreeService, projectPath}) - const exists = await this.contextTreeService.exists(projectPath) - if (!exists) { - throw new ContextTreeNotInitializedError() - } + const exists = await this.contextTreeService.exists(projectPath) + if (!exists) { + throw new ContextTreeNotInitializedError() + } - await this.contextTreeService.delete(projectPath) - await this.contextTreeService.initialize(projectPath) - await this.contextTreeSnapshotService.initEmptySnapshot(projectPath) + await this.contextTreeService.delete(projectPath) + await this.contextTreeService.initialize(projectPath) + await this.contextTreeSnapshotService.initEmptySnapshot(projectPath) - // Best-effort: clear review backups and pending review statuses so /status starts fresh - try { - await this.reviewBackupStoreFactory(projectPath).clear() - } catch { - // Backup cleanup must never block the reset response - } + // Best-effort: clear review backups and pending review statuses so /status starts fresh + try { + await this.reviewBackupStoreFactory(projectPath).clear() + } catch { + // Backup cleanup must never block the reset response + } - try { - await this.clearPendingReviews(projectPath) - } catch { - // Review status cleanup must never block the reset response + try { + await this.clearPendingReviews(projectPath) + } catch { + // Review status cleanup must never block the reset response + } + + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.DAEMON_RESET_EXECUTED, { + outcome: 'success', + reset_scope: 'project', + }) + /* eslint-enable camelcase */ + return {success: true} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.DAEMON_RESET_EXECUTED, { + failure_kind: classifyResetFailure(error), + outcome: 'failure', + reset_scope: 'project', + }) + /* eslint-enable camelcase */ + throw error } + } +} - return {success: true} +function classifyResetFailure(error: unknown): string { + if (error instanceof ContextTreeNotInitializedError) return 'not_initialized' + if (error instanceof GitVcInitializedError) return 'git_vc_active' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'fs_access' } + + return 'unknown' } diff --git a/src/server/infra/transport/handlers/review-handler.ts b/src/server/infra/transport/handlers/review-handler.ts index d7e684bbb..fc3201994 100644 --- a/src/server/infra/transport/handlers/review-handler.ts +++ b/src/server/infra/transport/handlers/review-handler.ts @@ -1,11 +1,15 @@ import {mkdir, unlink, writeFile} from 'node:fs/promises' import {dirname, join, relative} from 'node:path' +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ICurateLogStore} from '../../../core/interfaces/storage/i-curate-log-store.js' import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {IReviewBackupStore} from '../../../core/interfaces/storage/i-review-backup-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type AgentChangeOperation, type ReviewDecideTaskRequest, @@ -20,6 +24,8 @@ import { type ReviewSetDisabledResponse, } from '../../../../shared/transport/events/review-events.js' import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../constants.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' // ── Types ──────────────────────────────────────────────────────────────────── @@ -28,6 +34,7 @@ type CurateLogStoreFactory = (projectPath: string) => ICurateLogStore type ReviewBackupStoreFactory = (projectPath: string) => IReviewBackupStore export interface ReviewHandlerDeps { + analyticsClient?: IAnalyticsClient curateLogStoreFactory: CurateLogStoreFactory /** Called after all pending ops for a task are decided. Used to notify TUI clients. */ onResolved?: (info: {projectPath: string; taskId: string}) => void @@ -41,6 +48,7 @@ type PendingOp = { additionalFilePaths?: string[] logId: string operationIndex: number + type: string } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -66,6 +74,7 @@ function projectContextTreeFilePath(absoluteFilePath: string | undefined, contex * Mirrors the per-file logic in review-api-handler.ts but operates at task scope. */ export class ReviewHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly curateLogStoreFactory: CurateLogStoreFactory private readonly onResolved: ReviewHandlerDeps['onResolved'] private readonly projectConfigStore: IProjectConfigStore @@ -74,6 +83,7 @@ export class ReviewHandler { private readonly transport: ITransportServer constructor(deps: ReviewHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.curateLogStoreFactory = deps.curateLogStoreFactory this.onResolved = deps.onResolved this.projectConfigStore = deps.projectConfigStore @@ -109,11 +119,171 @@ export class ReviewHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Review] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleDecideTask( {decision, filePaths: filterPaths, taskId}: ReviewDecideTaskRequest, clientId: string, ): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const decisionEvent = decision === 'approved' ? AnalyticsEventNames.REVIEW_APPROVED : AnalyticsEventNames.REVIEW_REJECTED + + try { + return await this.runDecideTask({decision, filterPaths, projectPath, taskId}) + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + failure_kind: classifyReviewFailure(error), + operation_kind: 'unknown', + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + throw error + } + } + + private async handleGetDisabled(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const config = await this.projectConfigStore.read(projectPath) + if (!config) { + throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) + } + + return {reviewDisabled: config.reviewDisabled === true} + } + + private async handleListOperations(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const config = await this.projectConfigStore.read(projectPath) + if (!config) { + throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) + } + + if (config.reviewDisabled === true) return {operations: []} + + const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + const store = this.curateLogStoreFactory(projectPath) + const entries = await store.list({limit: 200, status: ['completed']}) + + const operations: AgentChangeOperation[] = [] + for (const entry of entries) { + for (const op of entry.operations) { + if (op.status === 'failed') continue + const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) + if (!filePath) continue + + const projected: AgentChangeOperation = { + filePath, + opCreatedAt: entry.startedAt, + taskId: entry.taskId, + type: op.type, + } + if (op.impact) projected.impact = op.impact + if (op.reason) projected.reason = op.reason + if (op.summary) projected.summary = op.summary + if (op.reviewStatus) projected.reviewStatus = op.reviewStatus + operations.push(projected) + } + } + + return {operations} + } + + private async handlePending(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + const store = this.curateLogStoreFactory(projectPath) + const entries = await store.list({status: ['completed']}) + + const taskMap = new Map() + + for (const entry of entries) { + for (const op of entry.operations) { + // Skip failed ops (e.g. validation errors) — they were never applied to disk + if (op.reviewStatus !== 'pending' || op.status === 'failed') continue + + let ops = taskMap.get(entry.taskId) + if (!ops) { + ops = [] + taskMap.set(entry.taskId, ops) + } + + const pendingOp: ReviewPendingOperation = {path: op.path, type: op.type} + const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) + if (filePath) pendingOp.filePath = filePath + + if (op.impact) pendingOp.impact = op.impact + if (op.reason) pendingOp.reason = op.reason + if (op.previousSummary) pendingOp.previousSummary = op.previousSummary + if (op.summary) pendingOp.summary = op.summary + ops.push(pendingOp) + } + } + + const tasks: ReviewPendingTask[] = [...taskMap.entries()].map(([taskId, operations]) => ({ + operations, + taskId, + })) + const pendingCount = tasks.reduce((sum, t) => sum + t.operations.length, 0) + + return {pendingCount, tasks} + } + + private async handleSetDisabled( + {reviewDisabled}: ReviewSetDisabledRequest, + clientId: string, + ): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + try { + const config = await this.projectConfigStore.read(projectPath) + if (!config) { + throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) + } + + const updated = config.withReviewDisabled(reviewDisabled) + await this.projectConfigStore.write(updated, projectPath) + + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.REVIEW_TOGGLED, { + new_state: reviewDisabled ? 'disabled' : 'enabled', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + + return {reviewDisabled} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.REVIEW_TOGGLED, { + failure_kind: classifyReviewFailure(error), + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + throw error + } + } + + private async runDecideTask(params: { + decision: 'approved' | 'rejected' + filterPaths?: string[] + projectPath: string + taskId: string + }): Promise { + const {decision, filterPaths, projectPath, taskId} = params const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) const store = this.curateLogStoreFactory(projectPath) @@ -139,7 +309,7 @@ export class ReviewHandler { pendingByPath.set(rel, ops) } - ops.push({additionalFilePaths: op.additionalFilePaths, logId: entry.id, operationIndex: i}) + ops.push({additionalFilePaths: op.additionalFilePaths, logId: entry.id, operationIndex: i, type: String(op.type)}) } } @@ -221,109 +391,55 @@ export class ReviewHandler { // Best-effort notification — never block the response } - const totalCount = fileResults.reduce((sum, {ops}) => sum + ops.length, 0) - return {files: fileResults.map(({path, reverted}) => ({path, reverted})), totalCount} - } - - private async handleGetDisabled(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const config = await this.projectConfigStore.read(projectPath) - if (!config) { - throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) - } - - return {reviewDisabled: config.reviewDisabled === true} - } - - private async handleListOperations(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const config = await this.projectConfigStore.read(projectPath) - if (!config) { - throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) - } - - if (config.reviewDisabled === true) return {operations: []} - - const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) - const store = this.curateLogStoreFactory(projectPath) - const entries = await store.list({limit: 200, status: ['completed']}) - - const operations: AgentChangeOperation[] = [] - for (const entry of entries) { - for (const op of entry.operations) { - if (op.status === 'failed') continue - const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) - if (!filePath) continue - - const projected: AgentChangeOperation = { - filePath, - opCreatedAt: entry.startedAt, - taskId: entry.taskId, - type: op.type, + const decisionEvent = decision === 'approved' ? AnalyticsEventNames.REVIEW_APPROVED : AnalyticsEventNames.REVIEW_REJECTED + if (fileResults.length === 0) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + failure_kind: 'not_found', + operation_kind: 'unknown', + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + } else { + for (const fileResult of fileResults) { + for (const op of fileResult.ops) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + operation_kind: op.type.toLowerCase(), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ } - if (op.impact) projected.impact = op.impact - if (op.reason) projected.reason = op.reason - if (op.summary) projected.summary = op.summary - if (op.reviewStatus) projected.reviewStatus = op.reviewStatus - operations.push(projected) } - } - - return {operations} - } - - private async handlePending(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) - const store = this.curateLogStoreFactory(projectPath) - const entries = await store.list({status: ['completed']}) - - const taskMap = new Map() - - for (const entry of entries) { - for (const op of entry.operations) { - // Skip failed ops (e.g. validation errors) — they were never applied to disk - if (op.reviewStatus !== 'pending' || op.status === 'failed') continue - let ops = taskMap.get(entry.taskId) - if (!ops) { - ops = [] - taskMap.set(entry.taskId, ops) - } - - const pendingOp: ReviewPendingOperation = {path: op.path, type: op.type} - const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) - if (filePath) pendingOp.filePath = filePath - - if (op.impact) pendingOp.impact = op.impact - if (op.reason) pendingOp.reason = op.reason - if (op.previousSummary) pendingOp.previousSummary = op.previousSummary - if (op.summary) pendingOp.summary = op.summary - ops.push(pendingOp) + // Per-file rejections from Promise.allSettled — emit failure rows + const rejectedCount = settled.filter((r) => r.status === 'rejected').length + for (let i = 0; i < rejectedCount; i++) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + failure_kind: 'snapshot_missing', + operation_kind: 'unknown', + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ } } - const tasks: ReviewPendingTask[] = [...taskMap.entries()].map(([taskId, operations]) => ({ - operations, - taskId, - })) - const pendingCount = tasks.reduce((sum, t) => sum + t.operations.length, 0) - - return {pendingCount, tasks} + const totalCount = fileResults.reduce((sum, {ops}) => sum + ops.length, 0) + return {files: fileResults.map(({path, reverted}) => ({path, reverted})), totalCount} } +} - private async handleSetDisabled( - {reviewDisabled}: ReviewSetDisabledRequest, - clientId: string, - ): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const config = await this.projectConfigStore.read(projectPath) - if (!config) { - throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) - } - - const updated = config.withReviewDisabled(reviewDisabled) - await this.projectConfigStore.write(updated, projectPath) - return {reviewDisabled} +function classifyReviewFailure(error: unknown): string { + if (error instanceof Error && error.message.includes('not initialized')) return 'unknown' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'config_write' } + + if (error instanceof Error && /write|ENOENT|EACCES|EPERM|disk/.test(error.message)) return 'config_write' + return 'unknown' } diff --git a/src/server/infra/transport/handlers/settings-handler.ts b/src/server/infra/transport/handlers/settings-handler.ts index d48410cce..e625c8449 100644 --- a/src/server/infra/transport/handlers/settings-handler.ts +++ b/src/server/infra/transport/handlers/settings-handler.ts @@ -1,3 +1,5 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type { SettingsErrorDTO, SettingsGetRequest, @@ -11,14 +13,75 @@ import type { SettingsSetResponse, } from '../../../../shared/transport/events/settings-events.js' import type {SettingDescriptor, SettingItem} from '../../../core/domain/entities/settings.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ISettingsStore} from '../../../core/interfaces/storage/i-settings-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {SettingsEvents} from '../../../../shared/transport/events/settings-events.js' -import {findSettingDescriptor, SETTINGS_REGISTRY} from '../../../core/domain/entities/settings.js' -import {InvalidSettingValueError, UnknownSettingKeyError} from '../../storage/settings-validator.js' +import {SETTINGS_REGISTRY} from '../../../core/domain/entities/settings.js' +import {processLog} from '../../../utils/process-logger.js' +import { + InvalidSettingValueError, + ReadonlySettingKeyError, + UnknownSettingKeyError, +} from '../../storage/settings-validator.js' + +/** + * Wire-acceptable shape for a `readonly-info` key's live value. Plain + * JSON-compatible primitive or object, or `undefined` when the + * provider has nothing to report. Strings, arrays, and functions are + * deliberately excluded so callers cannot smuggle unsupported shapes + * into the settings surface. + */ +export type ReadonlyInfoSnapshot = boolean | number | Readonly> | undefined + +/** + * Resolver for a `readonly-info` key's live value. Called by LIST and + * GET at request time. May return synchronously or via a Promise. + * Throwing is non-fatal: the handler maps a thrown provider error to + * `code: 'invalid_value'` so a single broken provider cannot crash the + * settings surface for every key. + */ +export type ReadonlyInfoProvider = () => Promise | ReadonlyInfoSnapshot + +/** + * Facade over `GlobalConfigHandler` for the `analytics.share` setting. + * The settings handler routes GET/SET/RESET/LIST for that key through + * this facade instead of `FileSettingsStore`, so the canonical storage + * in `config.json`, the device-id seeding race fix, the sync analytics + * cache, and the abort-on-disable side-effect are all preserved. + * + * Structurally satisfied by `GlobalConfigHandler` (no `implements` + * needed); tests pass a hand-rolled stub. + */ +export interface AnalyticsEnabledFacade { + getCurrentAnalytics(): Promise + setAnalyticsValue(value: boolean): Promise<{current: boolean; previous: boolean}> +} export interface SettingsHandlerDeps { + readonly analyticsClient?: IAnalyticsClient + /** + * Facade for the `analytics.share` writable key. When set, + * GET/SET/RESET/LIST for `analytics.share` route through this facade + * instead of the file store. When unset, the key surfaces with + * `current: undefined`. + */ + readonly globalConfigHandler?: AnalyticsEnabledFacade + /** + * Live-value resolvers for `readonly-info` keys, keyed by descriptor key. + * t3 (analytics.status) registers `'analytics.status' -> getAnalyticsStatus` + * here; in t1 the map is empty and readonly-info rows surface + * `current: undefined`. + */ + readonly infoProviders?: ReadonlyMap + /** + * Override the descriptor registry. Defaults to `SETTINGS_REGISTRY`. + * Tests inject a small registry containing the variant under test + * (e.g. a single `readonly-info` descriptor). + */ + readonly registry?: readonly SettingDescriptor[] readonly store: ISettingsStore readonly transport: ITransportServer } @@ -28,12 +91,31 @@ export interface SettingsHandlerDeps { * validation to the injected store; surfaces validator errors as typed * structured responses (`{ok: false, error: {...}}`) so no raw exceptions * leak across the wire. + * + * Readonly-info keys are gated at the top of SET and RESET — those paths + * return `code: 'read_only'` without ever touching the store. LIST and + * GET both resolve the live value via the injected `infoProviders` map; + * a missing provider yields `current: undefined` on both paths. A + * throwing provider is handled asymmetrically: GET surfaces the failure + * as a top-level `code: 'invalid_value'` response (the caller asked for + * that specific key, so the error matters), while LIST isolates the + * failure to that one row (`current: undefined`, daemon log captures the + * error) so a single broken provider cannot blank the whole settings + * surface. */ export class SettingsHandler { + private readonly analyticsClient: IAnalyticsClient | undefined + private readonly globalConfigHandler: AnalyticsEnabledFacade | undefined + private readonly infoProviders: ReadonlyMap + private readonly registry: readonly SettingDescriptor[] private readonly store: ISettingsStore private readonly transport: ITransportServer public constructor(deps: SettingsHandlerDeps) { + this.analyticsClient = deps.analyticsClient + this.globalConfigHandler = deps.globalConfigHandler + this.infoProviders = deps.infoProviders ?? new Map() + this.registry = deps.registry ?? SETTINGS_REGISTRY this.store = deps.store this.transport = deps.transport } @@ -44,12 +126,30 @@ export class SettingsHandler { async () => { const items = await this.store.list() const byKey = new Map(items.map((item) => [item.key, item])) - return { - items: SETTINGS_REGISTRY.map((descriptor) => { + // Per-row try/catch so one throwing readonly-info provider does + // not blank the whole list. Failed rows surface `current: undefined` + // (same shape as "no provider registered"); the daemon log captures + // the actual error for debugging. GET keeps its richer + // `code: 'invalid_value'` error path because GET is single-key + // and the caller asked specifically for that key. + const dtoItems = await Promise.all( + this.registry.map(async (descriptor) => { const stored = byKey.get(descriptor.key) - return descriptorToDTO(descriptor, stored?.current ?? descriptor.default) + let current: SettingItem['current'] + try { + current = await this.resolveCurrent(descriptor, stored) + } catch (error) { + processLog( + `[Settings] readonly-info provider for '${descriptor.key}' failed: ${error instanceof Error ? error.message : String(error)}`, + ) + current = undefined + } + + return descriptorToDTO(descriptor, current) }), - } + ) + + return {items: dtoItems} }, ) @@ -58,7 +158,13 @@ export class SettingsHandler { async (data) => { try { const item = await this.store.get(data.key) - return {...toItemDTO(item), ok: true} + const descriptor = this.findDescriptor(data.key) + if (descriptor === undefined) { + return {error: errorToDTO(new UnknownSettingKeyError(data.key), data.key), ok: false} + } + + const current = await this.resolveCurrent(descriptor, item) + return {...descriptorToDTO(descriptor, current), ok: true} } catch (error) { return {error: errorToDTO(error, data.key), ok: false} } @@ -68,13 +174,109 @@ export class SettingsHandler { this.transport.onRequest( SettingsEvents.SET, async (data) => { - const typeError = checkValueType(data.key, data.value) - if (typeError !== undefined) return {error: typeError, ok: false} + const descriptor = this.findDescriptor(data.key) + if (descriptor?.type === 'readonly-info') { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: 'read_only', + outcome: 'failure', + setting_key: data.key, + value_kind: 'readonly-info', + }) + /* eslint-enable camelcase */ + return {error: readOnlyError(data.key), ok: false} + } + + // Global-config writables (analytics.share and any future ones) + // route through the injected facade. The file store stays + // untouched. Type check still applies (boolean for the only + // current case), so reuse `checkValueType` before delegating. + if (descriptor?.storage === 'global-config') { + const typeError = checkValueType(descriptor, data.key, data.value) + if (typeError !== undefined) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: 'validation', + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: typeError, ok: false} + } + + if (this.globalConfigHandler === undefined) { + return { + error: { + code: 'misconfigured', + key: data.key, + message: `'${data.key}' is stored in global config, but no globalConfigHandler facade was wired into SettingsHandler.`, + }, + ok: false, + } + } + + try { + await this.globalConfigHandler.setAnalyticsValue(data.value as boolean) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + outcome: 'success', + setting_key: data.key, + value_changed_from_default: descriptorDefault(descriptor) === undefined + ? undefined + : data.value !== descriptorDefault(descriptor), + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {ok: true, restartRequired: restartRequiredFor(descriptor)} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: errorToDTO(error, data.key, data.value), ok: false} + } + } + + const typeError = checkValueType(descriptor, data.key, data.value) + if (typeError !== undefined) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: 'validation', + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: typeError, ok: false} + } try { await this.store.set(data.key, data.value) - return {ok: true, restartRequired: restartRequiredFor(data.key)} + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + outcome: 'success', + setting_key: data.key, + value_changed_from_default: descriptorDefault(descriptor) === undefined + ? undefined + : data.value !== descriptorDefault(descriptor), + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {ok: true, restartRequired: restartRequiredFor(descriptor)} } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ return {error: errorToDTO(error, data.key, data.value), ok: false} } }, @@ -83,19 +285,183 @@ export class SettingsHandler { this.transport.onRequest( SettingsEvents.RESET, async (data) => { + const descriptor = this.findDescriptor(data.key) + if (descriptor?.type === 'readonly-info') { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + failure_kind: 'read_only', + outcome: 'failure', + setting_key: data.key, + value_kind: 'readonly-info', + }) + /* eslint-enable camelcase */ + return {error: readOnlyError(data.key), ok: false} + } + + // Reset on a global-config writable means "back to descriptor.default". + // For analytics.share the default is `false`, so we flip via the facade. + if (descriptor?.storage === 'global-config') { + if (this.globalConfigHandler === undefined) { + return { + error: { + code: 'misconfigured', + key: data.key, + message: `'${data.key}' is stored in global config, but no globalConfigHandler facade was wired into SettingsHandler.`, + }, + ok: false, + } + } + + // The facade interface is boolean-only (`setAnalyticsValue(value: boolean)`). + // If a future descriptor is added with storage='global-config' and a + // non-boolean type, refuse explicitly instead of silently coercing + // the default to `false`. + if (descriptor.type !== 'boolean') { + return { + error: { + code: 'misconfigured', + key: data.key, + message: `'${data.key}' has storage='global-config' but type='${descriptor.type}'; the facade only supports boolean global-config keys.`, + }, + ok: false, + } + } + + try { + const defaultValue: boolean = descriptor.default + await this.globalConfigHandler.setAnalyticsValue(defaultValue) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + outcome: 'success', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {ok: true, restartRequired: restartRequiredFor(descriptor)} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: errorToDTO(error, data.key), ok: false} + } + } + try { await this.store.reset(data.key) - return {ok: true, restartRequired: restartRequiredFor(data.key)} + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + outcome: 'success', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {ok: true, restartRequired: restartRequiredFor(descriptor)} } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ return {error: errorToDTO(error, data.key), ok: false} } }, ) } + + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Settings] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private findDescriptor(key: string): SettingDescriptor | undefined { + return this.registry.find((d) => d.key === key) + } + + /** + * Resolves the value to surface on the DTO's `current` field. + * + * - Writable descriptors (`boolean` / `integer`): the stored override + * (if any) wins, else the registered default. + * - Readonly-info: the injected provider, if registered. Missing + * provider -> `undefined`. Provider throws -> rethrow so the GET + * handler can map to `code: 'invalid_value'` (LIST swallows below). + */ + private async resolveCurrent( + descriptor: SettingDescriptor, + stored: SettingItem | undefined, + ): Promise { + if (descriptor.type === 'readonly-info') { + const provider = this.infoProviders.get(descriptor.key) + if (provider === undefined) return undefined + return provider() + } + + if (descriptor.storage === 'global-config') { + // Global-config-stored values (analytics.share) live in + // config.json, not settings.json. Without an injected facade we + // cannot resolve — surface `undefined` so the row still renders + // rather than crashing. + if (this.globalConfigHandler === undefined) return undefined + return this.globalConfigHandler.getCurrentAnalytics() + } + + if (stored?.current !== undefined) return stored.current + return descriptor.default + } +} + +function classifySettingsFailure(error: unknown): string { + if (error instanceof ReadonlySettingKeyError) return 'read_only' + if (error instanceof UnknownSettingKeyError) return 'unknown_key' + if (error instanceof InvalidSettingValueError) return 'validation' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'config_write' + } + + return 'unknown' +} + +function restartRequiredFor(descriptor: SettingDescriptor | undefined): boolean { + return descriptor?.restartRequired ?? true } -function restartRequiredFor(key: string): boolean { - return findSettingDescriptor(key)?.restartRequired ?? true +function descriptorDefault(descriptor: SettingDescriptor | undefined): boolean | number | undefined { + if (descriptor === undefined) return undefined + if (descriptor.type === 'readonly-info') return undefined + return descriptor.default +} + +function writableValueKind( + descriptor: SettingDescriptor | undefined, +): 'boolean' | 'integer' | 'readonly-info' { + if (descriptor === undefined) return 'integer' + return descriptor.type +} + +function readOnlyError(key: string): SettingsErrorDTO { + return { + code: 'read_only', + key, + message: `Setting '${key}' is read-only and cannot be written or reset.`, + } } /** @@ -110,9 +476,13 @@ function restartRequiredFor(key: string): boolean { * Range, coupling, and fractional-number violations are left to the store's * validator and still surface as `invalid_value`. */ -function checkValueType(key: string, value: boolean | number): SettingsErrorDTO | undefined { - const descriptor = findSettingDescriptor(key) +function checkValueType( + descriptor: SettingDescriptor | undefined, + key: string, + value: boolean | number, +): SettingsErrorDTO | undefined { if (descriptor === undefined) return undefined + if (descriptor.type === 'readonly-info') return readOnlyError(key) const got = typeof value if (descriptor.type === 'integer' && got !== 'number') { @@ -140,19 +510,12 @@ function checkValueType(key: string, value: boolean | number): SettingsErrorDTO return undefined } -function toItemDTO(item: SettingItem): SettingsItemDTO { - const descriptor = findSettingDescriptor(item.key) - if (descriptor === undefined) { - throw new Error(`Setting '${item.key}' resolved to no descriptor — registry/store drift`) - } - - return descriptorToDTO(descriptor, item.current) -} - -function descriptorToDTO(descriptor: SettingDescriptor, current: boolean | number): SettingsItemDTO { +function descriptorToDTO( + descriptor: SettingDescriptor, + current: SettingItem['current'], +): SettingsItemDTO { const dto: SettingsItemDTO = { current, - default: descriptor.default, description: descriptor.description, key: descriptor.key, restartRequired: descriptor.restartRequired, @@ -160,15 +523,26 @@ function descriptorToDTO(descriptor: SettingDescriptor, current: boolean | numbe } if (descriptor.category !== undefined) dto.category = descriptor.category if (descriptor.type === 'integer') { + dto.default = descriptor.default dto.min = descriptor.min dto.max = descriptor.max if (descriptor.unit !== undefined) dto.unit = descriptor.unit + } else if (descriptor.type === 'boolean') { + dto.default = descriptor.default } + // readonly-info: no default, no min/max, no unit. Intentionally omitted + // from the wire shape so the CLI / TUI render path can branch on the + // absence of `default`. + return dto } function errorToDTO(error: unknown, key: string, value?: unknown): SettingsErrorDTO { + if (error instanceof ReadonlySettingKeyError) { + return {code: 'read_only', key: error.key, message: error.message} + } + if (error instanceof UnknownSettingKeyError) { return {code: 'unknown_key', key: error.key, message: error.message} } diff --git a/src/server/infra/transport/handlers/source-handler.ts b/src/server/infra/transport/handlers/source-handler.ts index 61864443d..16fc132f5 100644 --- a/src/server/infra/transport/handlers/source-handler.ts +++ b/src/server/infra/transport/handlers/source-handler.ts @@ -1,5 +1,9 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type SourceAddRequest, type SourceAddResponse, @@ -10,18 +14,23 @@ import { type SourceRemoveResponse, } from '../../../../shared/transport/events/source-events.js' import {addSource, listSourceStatuses, removeSource} from '../../../core/domain/source/source-operations.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface SourceHandlerDeps { + analyticsClient?: IAnalyticsClient resolveProjectPath: ProjectPathResolver transport: ITransportServer } export class SourceHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: SourceHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport } @@ -32,6 +41,14 @@ export class SourceHandler { async (data, clientId) => { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) const result = addSource(projectPath, data.targetPath, data.alias) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SOURCE_ADDED, { + ...(result.success ? {} : {failure_kind: 'add_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(projectPath), + ...(result.success ? {source_origin_hash: hashProjectPath(data.targetPath)} : {}), + }) + /* eslint-enable camelcase */ return { message: result.message, success: result.success, @@ -44,6 +61,13 @@ export class SourceHandler { async (data, clientId) => { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) const result = removeSource(projectPath, data.aliasOrPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SOURCE_REMOVED, { + ...(result.success ? {} : {failure_kind: 'remove_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return { message: result.message, success: result.success, @@ -63,4 +87,18 @@ export class SourceHandler { }, ) } + + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Source] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } } diff --git a/src/server/infra/transport/handlers/space-handler.ts b/src/server/infra/transport/handlers/space-handler.ts index 04be9df5f..ba5a9adbb 100644 --- a/src/server/infra/transport/handlers/space-handler.ts +++ b/src/server/infra/transport/handlers/space-handler.ts @@ -1,3 +1,6 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IContextTreeMerger} from '../../../core/interfaces/context-tree/i-context-tree-merger.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' @@ -9,6 +12,7 @@ import type {ITeamService} from '../../../core/interfaces/services/i-team-servic import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {PullEvents} from '../../../../shared/transport/events/pull-events.js' import { SpaceEvents, @@ -25,6 +29,7 @@ import { SpaceNotFoundError, } from '../../../core/domain/errors/task-error.js' import {syncConfigToXdg} from '../../../utils/config-xdg-sync.js' +import {processLog} from '../../../utils/process-logger.js' import { guardAgainstGitVc, hasAnyChanges, @@ -34,6 +39,7 @@ import { } from './handler-types.js' export interface SpaceHandlerDeps { + analyticsClient?: IAnalyticsClient broadcastToProject: ProjectBroadcaster cogitPullService: ICogitPullService contextTreeMerger: IContextTreeMerger @@ -53,6 +59,7 @@ export interface SpaceHandlerDeps { * Business logic for space listing and switching — no terminal/UI calls. */ export class SpaceHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly broadcastToProject: ProjectBroadcaster private readonly cogitPullService: ICogitPullService private readonly contextTreeMerger: IContextTreeMerger @@ -67,6 +74,7 @@ export class SpaceHandler { private readonly transport: ITransportServer constructor(deps: SpaceHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.broadcastToProject = deps.broadcastToProject this.cogitPullService = deps.cogitPullService this.contextTreeMerger = deps.contextTreeMerger @@ -89,6 +97,20 @@ export class SpaceHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Space] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleList(clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) @@ -140,6 +162,16 @@ export class SpaceHandler { // No-op: switching to the currently active space if (existingConfig.spaceId === data.spaceId) { + if (existingConfig.spaceId) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SPACE_SWITCHED, { + from_space_id: existingConfig.spaceId, + outcome: 'success', + to_space_id: data.spaceId, + }) + /* eslint-enable camelcase */ + } + return { config: { spaceId: existingConfig.spaceId, @@ -258,6 +290,16 @@ export class SpaceHandler { // Pull failed and config was rolled back — return the old config with success: false if (pullError) { + if (existingConfig.spaceId) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SPACE_SWITCHED, { + failure_kind: 'pull_failed', + from_space_id: existingConfig.spaceId, + outcome: 'failure', + }) + /* eslint-enable camelcase */ + } + return { config: { spaceId: existingConfig.spaceId, @@ -271,6 +313,16 @@ export class SpaceHandler { } } + if (existingConfig.spaceId && newConfig.spaceId) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SPACE_SWITCHED, { + from_space_id: existingConfig.spaceId, + outcome: 'success', + to_space_id: newConfig.spaceId, + }) + /* eslint-enable camelcase */ + } + return { config: { spaceId: newConfig.spaceId, diff --git a/src/server/infra/transport/handlers/swarm-handler.ts b/src/server/infra/transport/handlers/swarm-handler.ts new file mode 100644 index 000000000..044df542a --- /dev/null +++ b/src/server/infra/transport/handlers/swarm-handler.ts @@ -0,0 +1,120 @@ +/** + * Handler for `swarm:*` transport events. + * + * Thin emit surface for the federated-memory-provider operations + * (`brv swarm query`, `brv swarm curate`, `brv swarm onboard`). The + * coordinator itself still lives in the agent process at + * `src/agent/infra/swarm/swarm-coordinator.ts` — the daemon does NOT + * proxy the operations today. The CLI commands run swarm-coordinator + * client-side and dispatch one of these three transport events to + * the daemon when the operation terminates. The handler validates + * the payload against the per-event Zod schema and forwards to + * `analyticsClient.track()`. + * + * Mirrors the try/processLog pattern from `SettingsHandler` and + * `MigrateHandler` so analytics failures never affect command + * outcomes — the CLI gets `tracked: false` plus a reason; nothing + * throws. + * + * Forward direction (out of scope for this commit): if the swarm + * coordinator is moved into the daemon process, the SAME three event + * names extend to carry the operation request payloads. Only the + * handler internals change; CLI / LLM-tool callers stay unchanged. + */ + +import type { + SwarmTrackOnboardedRequest, + SwarmTrackQueryCompletedRequest, + SwarmTrackResponse, + SwarmTrackStoreCompletedRequest, +} from '../../../../shared/transport/events/swarm-events.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {SwarmOnboardedSchema} from '../../../../shared/analytics/events/swarm-onboarded.js' +import {SwarmQueryCompletedSchema} from '../../../../shared/analytics/events/swarm-query-completed.js' +import {SwarmStoreCompletedSchema} from '../../../../shared/analytics/events/swarm-store-completed.js' +import {SwarmEvents} from '../../../../shared/transport/events/swarm-events.js' +import {processLog} from '../../../utils/process-logger.js' + +export interface SwarmHandlerDeps { + /** + * Optional — when undefined the handler still registers the transport + * events but returns `{tracked: false, reason: 'analytics-unavailable'}` + * for every call. Lets the wiring exist before analytics is plumbed + * in test harnesses. + */ + readonly analyticsClient?: IAnalyticsClient + transport: ITransportServer +} + +export class SwarmHandler { + private readonly analyticsClient: IAnalyticsClient | undefined + private readonly transport: ITransportServer + + constructor(deps: SwarmHandlerDeps) { + this.analyticsClient = deps.analyticsClient + this.transport = deps.transport + } + + setup(): void { + this.transport.onRequest( + SwarmEvents.TRACK_QUERY_COMPLETED, + (data) => this.handleTrackQueryCompleted(data), + ) + this.transport.onRequest( + SwarmEvents.TRACK_STORE_COMPLETED, + (data) => this.handleTrackStoreCompleted(data), + ) + this.transport.onRequest( + SwarmEvents.TRACK_ONBOARDED, + (data) => this.handleTrackOnboarded(data), + ) + } + + private handleTrackOnboarded(data: SwarmTrackOnboardedRequest): SwarmTrackResponse { + const parsed = SwarmOnboardedSchema.safeParse(data) + if (!parsed.success) return {reason: 'schema-rejection', tracked: false} + return this.runEmit(AnalyticsEventNames.SWARM_ONBOARDED, (client) => + client.track(AnalyticsEventNames.SWARM_ONBOARDED, parsed.data), + ) + } + + private handleTrackQueryCompleted(data: SwarmTrackQueryCompletedRequest): SwarmTrackResponse { + // Validate at the transport boundary — the CLI is an external trust + // boundary even though we ship it ourselves. + const parsed = SwarmQueryCompletedSchema.safeParse(data) + if (!parsed.success) return {reason: 'schema-rejection', tracked: false} + return this.runEmit(AnalyticsEventNames.SWARM_QUERY_COMPLETED, (client) => + client.track(AnalyticsEventNames.SWARM_QUERY_COMPLETED, parsed.data), + ) + } + + private handleTrackStoreCompleted(data: SwarmTrackStoreCompletedRequest): SwarmTrackResponse { + const parsed = SwarmStoreCompletedSchema.safeParse(data) + if (!parsed.success) return {reason: 'schema-rejection', tracked: false} + return this.runEmit(AnalyticsEventNames.SWARM_STORE_COMPLETED, (client) => + client.track(AnalyticsEventNames.SWARM_STORE_COMPLETED, parsed.data), + ) + } + + /** + * Shared try/catch wrapper. The thunk does the literal-narrowed + * `track(NAME, props)` call so TS infers `PropsArg` per event — no + * generic widening, no `as` cast. + */ + private runEmit(eventLabel: string, fn: (client: IAnalyticsClient) => void): SwarmTrackResponse { + const client = this.analyticsClient + if (!client) return {reason: 'analytics-unavailable', tracked: false} + try { + fn(client) + return {tracked: true} + } catch (error) { + processLog( + `[Swarm] analytics track ${eventLabel} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return {reason: 'analytics-throw', tracked: false} + } + } +} diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 6b6e7240c..0bab4d3b2 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -1,6 +1,9 @@ import fs from 'node:fs' import {basename, join} from 'node:path' +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' import type {GitCommit, GitDiffSide, IGitService} from '../../../core/interfaces/services/i-git-service.js' @@ -10,6 +13,7 @@ import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-proje import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {IVcGitConfig, IVcGitConfigStore} from '../../../core/interfaces/vc/i-vc-git-config-store.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type IVcAddRequest, type IVcAddResponse, @@ -62,6 +66,8 @@ import {GitAuthError, GitError} from '../../../core/domain/errors/git-error.js' import {NotAuthenticatedError} from '../../../core/domain/errors/task-error.js' import {VcError} from '../../../core/domain/errors/vc-error.js' import {ensureContextTreeGitignore, ensureGitignoreEntries} from '../../../utils/gitignore.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {generateContextTreeIndex, regenerateContextTreeIndex} from '../../context-tree/index-generator.js' import {buildCogitRemoteUrl, isValidBranchName, parseUserFacingUrl} from '../../git/cogit-url.js' import {type ProjectBroadcaster, type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' @@ -120,6 +126,13 @@ function resolveDiffSides(mode: VcDiffMode): {from: GitDiffSide; to: GitDiffSide } export interface IVcHandlerDeps { + /** + * Optional. When provided, the handler emits per-vc-event analytics at + * existing success terminals. Failure emits are NOT added in this pass + * because every catch block in this handler throws (no return), and the + * additive-only rule forbids new try/catch wrappers. + */ + analyticsClient?: IAnalyticsClient broadcastToProject: ProjectBroadcaster contextTreeService: IContextTreeService gitRemoteBaseUrl: string @@ -138,6 +151,7 @@ export interface IVcHandlerDeps { * Handles vc:* events (Version Control commands). */ export class VcHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly broadcastToProject: ProjectBroadcaster private readonly contextTreeService: IContextTreeService private readonly gitRemoteBaseUrl: string @@ -152,6 +166,7 @@ export class VcHandler { private readonly webAppUrl: string constructor(deps: IVcHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.broadcastToProject = deps.broadcastToProject this.gitRemoteBaseUrl = deps.gitRemoteBaseUrl this.contextTreeService = deps.contextTreeService @@ -325,6 +340,15 @@ export class VcHandler { ) } + /** + * Classify a remote URL into 'byterover' or 'external' for analytics + * segmentation. Matches the daemon's configured `gitRemoteBaseUrl` prefix. + */ + private classifyRemoteKind(url: string | undefined): 'byterover' | 'external' { + if (!url) return 'external' + return this.gitRemoteBaseUrl && url.startsWith(this.gitRemoteBaseUrl) ? 'byterover' : 'external' + } + private async computeDiff(directory: string, path: string, side: VcDiffSide): Promise { if (side === 'staged') { const [head, stage] = await Promise.all([ @@ -342,6 +366,20 @@ export class VcHandler { return {newContent: workingTree, oldContent: stage ?? '', path} } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Vc] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + /** * When force is NOT set, checks for uncommitted changes and throws * VcError(UNCOMMITTED_CHANGES) if the working tree is dirty. @@ -421,7 +459,18 @@ export class VcHandler { // but transport payloads are untrusted — validate at the boundary. if (data.action === 'create' || data.action === 'delete') { if (!data.name) throw new VcError('Branch name is required.', VcErrorCode.INVALID_BRANCH_NAME) - if (data.action === 'create') return this.handleBranchCreate(directory, data.name, data.startPoint) + if (data.action === 'create') { + const created = await this.handleBranchCreate(directory, data.name, data.startPoint) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_BRANCHED, { + from_default_branch: data.startPoint === undefined || data.startPoint === 'main', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return created + } + return this.handleBranchDelete(directory, data.name) } @@ -586,6 +635,14 @@ export class VcHandler { throw error } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_CHECKED_OUT, { + branch_kind: 'created', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {branch: data.branch, created: true, previousBranch} } @@ -630,6 +687,14 @@ export class VcHandler { throw error } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_CHECKED_OUT, { + branch_kind: 'existing', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {branch: data.branch, created: false, previousBranch} } @@ -727,6 +792,14 @@ export class VcHandler { throw new VcError(`Clone failed: ${msg}`, VcErrorCode.CLONE_FAILED) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_CLONED, { + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(cloneUrl), + }) + /* eslint-enable camelcase */ + return { gitDir: join(contextTreeDir, '.git'), spaceName, @@ -763,6 +836,14 @@ export class VcHandler { message: data.message, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_COMMIT, { + had_message: Boolean(data.message), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {message: commit.message, sha: commit.sha} } @@ -906,6 +987,14 @@ export class VcHandler { }), ) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_DISCARDED, { + discard_scope: filePaths.length > 1 ? 'all' : 'file', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {count: results.filter(Boolean).length} } @@ -941,6 +1030,14 @@ export class VcHandler { throw new VcError(message, VcErrorCode.FETCH_FAILED) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_FETCHED, { + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === remote)?.url), + }) + /* eslint-enable camelcase */ + return {remote} } @@ -961,6 +1058,14 @@ export class VcHandler { // 4. Add .brv entries to project .gitignore (prevents `git add .` fatal error from nested .git) await ensureGitignoreEntries(projectPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_INIT, { + had_existing_git_dir: reinitialized, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return { gitDir: join(contextTreeDir, '.git'), reinitialized, @@ -1050,6 +1155,13 @@ export class VcHandler { directory, message: data.message, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: false, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'continue'} } @@ -1071,6 +1183,13 @@ export class VcHandler { // Self-merge check const currentBranch = await this.gitService.getCurrentBranch({directory}) if (currentBranch && data.branch === currentBranch) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: true, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', alreadyUpToDate: true, branch: data.branch} } @@ -1109,6 +1228,13 @@ export class VcHandler { directory, message: data.message ?? `Merge branch '${data.branch}'`, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: false, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', branch: data.branch} } @@ -1116,11 +1242,25 @@ export class VcHandler { } if (result.alreadyUpToDate) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: true, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', alreadyUpToDate: true, branch: data.branch} } // Merge changed the topic set — refresh the derived navigation index. await this.regenerateIndexBestEffort(directory, projectPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: false, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', branch: data.branch} } @@ -1175,6 +1315,14 @@ export class VcHandler { directory, message: `Merge branch '${branch}' of ${remote}`, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_PULLED, { + branch_name_hash: hashProjectPath(branch), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === remote)?.url), + }) + /* eslint-enable camelcase */ return {alreadyUpToDate: false, branch} } @@ -1215,6 +1363,15 @@ export class VcHandler { await this.regenerateIndexBestEffort(directory, projectPath) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_PULLED, { + branch_name_hash: hashProjectPath(branch), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === remote)?.url), + }) + /* eslint-enable camelcase */ + return {alreadyUpToDate, branch} } @@ -1294,6 +1451,15 @@ export class VcHandler { throw new VcError(message, VcErrorCode.PUSH_FAILED) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_PUSHED, { + branch_name_hash: hashProjectPath(branch), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === 'origin')?.url), + }) + /* eslint-enable camelcase */ + return {alreadyUpToDate, branch, upstreamSet} } @@ -1328,6 +1494,15 @@ export class VcHandler { await this.gitService.removeRemote({directory, remote: 'origin'}) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_REMOTE_CHANGED, { + change_kind: 'removed', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(existingUrl), + }) + /* eslint-enable camelcase */ + return {action: 'remove'} } @@ -1378,6 +1553,15 @@ export class VcHandler { await this.projectConfigStore.write(updated, projectPath) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_REMOTE_CHANGED, { + change_kind: data.subcommand === 'add' ? 'added' : 'url_set', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(resolved.url), + }) + /* eslint-enable camelcase */ + return {action: data.subcommand === 'add' ? 'add' : 'set-url', url: resolved.url} } @@ -1417,6 +1601,14 @@ export class VcHandler { const isUnstage = Boolean(data.filePaths) || (mode === 'mixed' && (!data.ref || data.ref === 'HEAD')) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_RESET_EXECUTED, { + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + reset_mode: mode, + }) + /* eslint-enable camelcase */ + return { filesUnstaged: isUnstage ? result.filesChanged : undefined, headSha: isUnstage ? undefined : result.headSha, diff --git a/src/server/infra/transport/handlers/worktree-handler.ts b/src/server/infra/transport/handlers/worktree-handler.ts index e79215e34..77ce1f751 100644 --- a/src/server/infra/transport/handlers/worktree-handler.ts +++ b/src/server/infra/transport/handlers/worktree-handler.ts @@ -1,5 +1,9 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type WorktreeAddRequest, type WorktreeAddResponse, @@ -9,19 +13,24 @@ import { type WorktreeRemoveRequest, type WorktreeRemoveResponse, } from '../../../../shared/transport/events/worktree-events.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {addWorktree, findParentProject, listWorktrees, removeWorktree, resolveProject} from '../../project/resolve-project.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface WorktreeHandlerDeps { + analyticsClient?: IAnalyticsClient resolveProjectPath: ProjectPathResolver transport: ITransportServer } export class WorktreeHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: WorktreeHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport } @@ -45,11 +54,25 @@ export class WorktreeHandler { if (parent) { projectPath = parent } else if (!projectPath) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.WORKTREE_ADDED, { + failure_kind: 'no_parent_project', + outcome: 'failure', + project_path_hash: hashProjectPath(data.worktreePath), + }) + /* eslint-enable camelcase */ return {message: 'No parent project found for the target directory.', success: false} } } const result = addWorktree(projectPath, data.worktreePath, {force: data.force}) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.WORKTREE_ADDED, { + ...(result.success ? {} : {failure_kind: 'add_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return { backedUp: result.backedUp, message: result.message, @@ -63,6 +86,13 @@ export class WorktreeHandler { async (data) => { const targetPath = data.worktreePath const result = removeWorktree(targetPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.WORKTREE_REMOVED, { + ...(result.success ? {} : {failure_kind: 'remove_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(targetPath), + }) + /* eslint-enable camelcase */ return { message: result.message, success: result.success, @@ -95,4 +125,18 @@ export class WorktreeHandler { }, ) } + + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Worktree] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } } diff --git a/src/server/infra/transport/socket-io-transport-server.ts b/src/server/infra/transport/socket-io-transport-server.ts index 1adba341f..f2211fc02 100644 --- a/src/server/infra/transport/socket-io-transport-server.ts +++ b/src/server/infra/transport/socket-io-transport-server.ts @@ -2,6 +2,7 @@ import {instrument} from '@socket.io/admin-ui' import {createServer, Server as HttpServer, type RequestListener} from 'node:http' import {Server, Socket} from 'socket.io' +import type {ClientType} from '../../core/domain/client/client-info.js' import type {TransportServerConfig} from '../../core/domain/transport/types.js' import type { ConnectionHandler, @@ -18,6 +19,7 @@ import { TransportServerNotStartedError, } from '../../core/domain/errors/transport-error.js' import {transportLog} from '../../utils/process-logger.js' +import {clientKindContext} from './client-kind-context.js' /** * Internal protocol constants for request/response pattern. @@ -43,6 +45,16 @@ export class SocketIOTransportServer implements ITransportServer { private readonly config: Required private connectionHandlers: ConnectionHandler[] = [] private disconnectionHandlers: ConnectionHandler[] = [] + /** + * Optional lookup that resolves a Socket.IO clientId to its registered + * `ClientType`. When set and the lookup returns a non-undefined value, + * every incoming request handler invocation is wrapped in + * `clientKindContext.run({client_kind}, ...)` so SuperPropertiesResolver + * can stamp `client_kind` on the analytics envelope. Pre-filter the + * `agent` ClientType at the caller (return undefined) so agent-fork + * connections bypass the wrap entirely. + */ + private getClientKind: ((clientId: string) => ClientType | undefined) | undefined private httpRequestHandler?: RequestListener private httpServer: HttpServer | undefined private io: Server | undefined @@ -139,6 +151,17 @@ export class SocketIOTransportServer implements ITransportServer { } } + /** + * Register a lookup that maps a socket clientId to its ClientType. + * Used to stamp `client_kind` on analytics super-properties so handler + * emits inherit the originating client kind without per-handler wiring. + * Setter (not constructor injection) because ClientManager is constructed + * AFTER the transport server in brv-server.ts boot order. + */ + setGetClientKind(getter: (clientId: string) => ClientType | undefined): void { + this.getClientKind = getter + } + /** * Sets an HTTP request handler (e.g., Express app) to handle non-Socket.IO HTTP requests. * Must be called before start(). @@ -269,7 +292,16 @@ export class SocketIOTransportServer implements ITransportServer { private registerEventHandler(socket: Socket, event: string, handler: StoredRequestHandler): void { socket.on(event, async (data: unknown, callback?: (response: unknown) => void) => { try { - const result = await handler(data, socket.id) + // Wrap the handler in clientKindContext so SuperPropertiesResolver + // can stamp `client_kind` on any analytics event emitted during this + // handler invocation. Skip the wrap when no lookup is registered or the + // lookup returns undefined (agent-fork bypass / unregistered sockets). + const clientKind = this.getClientKind?.(socket.id) + const invokeHandler = (): Promise | unknown => handler(data, socket.id) + const result = await (clientKind + ? // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: clientKind}, invokeHandler) + : invokeHandler()) // Support both callback style and event-based response if (callback) { diff --git a/src/server/templates/skill/onboarding.md b/src/server/templates/skill/onboarding.md index a145d89f2..6e4cecc40 100644 --- a/src/server/templates/skill/onboarding.md +++ b/src/server/templates/skill/onboarding.md @@ -281,6 +281,32 @@ The persona you saved becomes seed knowledge for every future session. From here If the user invokes the tour again later, run it again — there is no state tracking, no "you've already seen this." A second tour is a re-orientation, not an error. The new persona save replaces (or augments) the previous one through normal curate behavior. +## Share Analytics (Opt-In) + +After the tour closes, ask **once** whether the user wants to share anonymous usage analytics with ByteRover. Sharing is opt-in: it defaults to off, and we do not flip it without an explicit yes. Do not volunteer details about local-only collection — that's an implementation detail, and surfacing it unprompted invites questions the tour shouldn't have to answer. If the user asks what's collected locally, answer plainly; otherwise stay scoped to the sharing decision. + +Place the ask _after_ the "Either way, you're set" close, as a single follow-up message — not bundled into Message 3: + +> "One optional ask before you go: if you'd like to help us improve ByteRover, you can opt in to share anonymous usage telemetry — things like which commands ran and how long they took. No query content, file contents, or memory is ever sent. You can change your mind anytime with `brv settings set analytics.share false`. +> +> Want to opt in? Either answer is fine." + +Handling the response: + +- **Yes** → run `brv settings set analytics.share true --yes` (or instruct the user to run it if you cannot), then confirm in one line: "Done — thanks. `brv settings set analytics.share false` reverses it anytime." +- **No / silence / "maybe later"** → one-line acknowledgement ("No problem — `brv settings set analytics.share true` is there whenever.") and stop. Do not re-ask in future sessions. + +Why this beat exists: + +- **Trust separation.** Local collection and shared telemetry are two different promises. Conflating them ("analytics is on") would undo the trust the tour just built. +- **One ask, never a nag.** One sentence, one question, one line of follow-up. If declined or skipped, the agent never raises it again — the user has the command if they change their mind. +- **Tour-adjacent, not tour-blocking.** The tour itself still ends at Message 3. A user who's done can disengage at the close without ever seeing this ask. + +Skip the ask entirely if: + +- Sharing is already enabled (`brv settings get analytics.share` returns true). +- The user signaled disengagement at the close ("ok", "got it", "thanks", no further input). Don't pull a yes/no out of someone who's already left. + ## What NOT To Do - Do NOT extend past 3 messages. @@ -291,6 +317,6 @@ If the user invokes the tour again later, run it again — there is no state tra - Do NOT prompt for an LLM provider, login, or any configuration. The tour runs with zero setup. - Do NOT skip the persona-shaped tailoring in Message 2 in favor of a generic "here's how retrieve works" explanation. The tailored example IS the value demo. - Do NOT tailor with hollow phrases like "As a Rust developer, you'll love…" or "Since you work on a CLI, you might want to…" — these read as templated personalization and erode trust faster than no tailoring at all. The tailored example must reference something **specific** the user said, paired with a **specific** action the agent will take. -- Do NOT turn the visible artifact in Message 1 into a confirmation step. No "Does this look right?" prompts. The artifact is shown so the user *feels* what was captured, not so they validate it. +- Do NOT turn the visible artifact in Message 1 into a confirmation step. No "Does this look right?" prompts. The artifact is shown so the user _feels_ what was captured, not so they validate it. - Do NOT manufacture a pain if the user didn't share one. Skip the pain-naming paragraph and the pain-ending demonstration in that case. A thinner tour is better than a fake one. - Do NOT overpromise on pains outside the context-memory family. If the user names a pain ByteRover doesn't solve (hallucinations, model speed, bad code generation), acknowledge briefly and redirect to the in-scope pain. Do NOT claim ByteRover fixes things it doesn't. diff --git a/src/server/utils/hash-path.ts b/src/server/utils/hash-path.ts new file mode 100644 index 000000000..3f65f2740 --- /dev/null +++ b/src/server/utils/hash-path.ts @@ -0,0 +1,17 @@ +import {createHash} from 'node:crypto' + +/** + * SHA-256 hex digest of a path string, used by analytics emits that want + * to identify a project / file / source without leaking the raw absolute + * path. Raw paths are on `FORBIDDEN_FIELD_NAMES` (and are PII-adjacent at + * volume); the hash gives downstream consumers a stable join key without + * revealing the source. + * + * Verbatim hash, no normalization — trailing slashes, case, and symlink + * resolution are caller-side concerns. Callers SHOULD pass the canonical + * absolute path their handler resolved (e.g. via `resolveProjectPath`) + * so the hash is stable across emits for the same project. + */ +export function hashProjectPath(path: string): string { + return createHash('sha256').update(path).digest('hex') +} diff --git a/src/server/utils/read-cli-version.ts b/src/server/utils/read-cli-version.ts new file mode 100644 index 000000000..0b0f26002 --- /dev/null +++ b/src/server/utils/read-cli-version.ts @@ -0,0 +1,29 @@ +import {readFileSync} from 'node:fs' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const FALLBACK_VERSION = 'unknown' + +/** + * Reads the CLI version from `package.json`. Walks up three directory + * levels from this file's location to find the project root, which works + * for both source (`src/server/utils/`) and compiled (`dist/server/utils/`) + * paths since both sit at the same depth. + * + * Returns `'unknown'` on any read or parse failure (best-effort). + */ +export function readCliVersion(): string { + try { + const currentDir = dirname(fileURLToPath(import.meta.url)) + // src/ and dist/ are 3 levels deep: server/utils/read-cli-version + const pkgPath = join(currentDir, '..', '..', '..', 'package.json') + const pkg: unknown = JSON.parse(readFileSync(pkgPath, 'utf8')) + if (typeof pkg === 'object' && pkg !== null && 'version' in pkg && typeof pkg.version === 'string') { + return pkg.version + } + } catch { + // Best-effort — return fallback + } + + return FALLBACK_VERSION +} diff --git a/src/shared/analytics/cli-metadata-schema.ts b/src/shared/analytics/cli-metadata-schema.ts new file mode 100644 index 000000000..87f73476c --- /dev/null +++ b/src/shared/analytics/cli-metadata-schema.ts @@ -0,0 +1,46 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Shared schema for the `cli_metadata` block. Source of truth for two + * call sites: + * + * 1. As the per-event analytics schema for `cli_invocation` (re-exported + * by `events/cli-invocation.ts` for catalog registration). + * 2. Wrapped as `CliRequestBaseSchema` so every client-originated request + * schema in M13.2 can extend it and carry the optional block. + * + * Strict mode rejects accidental extra fields at parse / emit time. The + * eight fields are all CLI-process detections the daemon cannot infer. + * Field NAMES verified outside `FORBIDDEN_FIELD_NAMES`. + */ +export const CliMetadataSchema = z + .object({ + client_sent_at: z.number().int().nonnegative(), + command_id: z.string().min(1), + flag_names: z.array(z.string()), + is_ci: z.boolean(), + is_tty: z.boolean(), + package_manager: z.enum(['npm', 'yarn', 'pnpm', 'bun', 'unknown']), + runtime: z.enum(['node', 'bun']), + terminal_program: z.string().min(1).optional(), + }) + .strict() + +/** + * Wrapper every client-originated request schema will extend (M13.2 sweep). + * The `cli_metadata` block is always optional — non-CLI clients (TUI, MCP, + * webui) keep working without filling it. + */ +export const CliRequestBaseSchema = z.object({ + cli_metadata: CliMetadataSchema.optional(), +}) + +/** + * Inferred type with index signature so it satisfies + * `IAnalyticsClient.track`'s `properties?: Record` parameter + * without any `as` cast or spread workaround at the emit site. + */ +export type CliMetadata = Record & z.infer + +export type CliRequestBase = z.infer diff --git a/src/shared/analytics/emit.ts b/src/shared/analytics/emit.ts new file mode 100644 index 000000000..ab3411c8a --- /dev/null +++ b/src/shared/analytics/emit.ts @@ -0,0 +1,47 @@ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import type {AnalyticsEventName} from './event-names.js' +import type {AnyAnalyticsEvent} from './events/index.js' + +import {AnalyticsEvents, type AnalyticsTrackPayload} from '../transport/events/analytics-events.js' + +/** + * Type-derived properties for a given event name. Combined with the + * generic `` on `emitAnalytics`, callers + * cannot pass an unknown event name and cannot pass mismatched + * properties for a known event. Magic-string typos (e.g. + * `'daemon_starts'`) and wrong-shape payloads (e.g. `tool_name` on + * `cli_invocation`) become compile errors instead of runtime drops. + */ +type PropsForEvent = Extract['properties'] + +/** + * If the event has no required properties (e.g. `daemon_start`), the + * `properties` argument is optional. Otherwise it is required. Implemented + * via a rest tuple so the call site stays ergonomic. + */ +type PropsArg = keyof PropsForEvent extends never + ? [properties?: PropsForEvent] + : [properties: PropsForEvent] + +/** + * Fire-and-forget analytics emission for non-forked daemon clients + * (TUI, oclif, MCP, webui). Uses `client.request` (no ack) so the caller + * never waits on the daemon. + * + * NEVER throws. If the client is not connected or `request` throws for + * any reason, the error is swallowed: analytics MUST NOT crash the caller. + */ +export function emitAnalytics( + client: ITransportClient, + event: E, + ...rest: PropsArg +): void { + const [properties] = rest + const payload: AnalyticsTrackPayload = {event, properties} + try { + client.request(AnalyticsEvents.TRACK, payload) + } catch { + // Intentional: analytics must not crash consumers. + } +} diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts new file mode 100644 index 000000000..b822295af --- /dev/null +++ b/src/shared/analytics/event-names.ts @@ -0,0 +1,71 @@ +/** + * Canonical wire-format names for every analytics event the daemon may emit. + * + * These are the values that travel as `event.name` in the analytics batch + * (see `AnalyticsBatch` in server/core/domain/analytics/batch.ts). + * + * Snake_case values per the analytics spec; the keys are SCREAMING_SNAKE for + * use as in-source constants. Adding a new event REQUIRES adding both: + * 1. A new entry here. + * 2. A new schema file in ./events/ and registration in ./events/index.ts. + * + * Some entries are deferred scaffolding (no producer yet — emitter lands in + * a future ticket). They are intentional, not Outside-In violations; the + * upcoming milestones will wire the producer alongside its consumer. + */ +export const AnalyticsEventNames = { + ANALYTICS_DISABLED: 'analytics_disabled', + AUTH_LOGIN: 'auth_login', + AUTH_LOGOUT: 'auth_logout', + BRV_INIT: 'brv_init', + CLI_INVOCATION: 'cli_invocation', + CONNECTOR_INSTALLED: 'connector_installed', + CONTENT_MIGRATED: 'content_migrated', + CONTEXT_TREE_FILE_EDITED: 'context_tree_file_edited', + CURATE_OPERATION_APPLIED: 'curate_operation_applied', + CURATE_RUN_COMPLETED: 'curate_run_completed', + DAEMON_RESET_EXECUTED: 'daemon_reset_executed', + DAEMON_START: 'daemon_start', + HUB_PACKAGE_INSTALLED: 'hub_package_installed', + HUB_REGISTRY_ADDED: 'hub_registry_added', + HUB_REGISTRY_REMOVED: 'hub_registry_removed', + MCP_SESSION_ENDED: 'mcp_session_ended', + MCP_SESSION_START: 'mcp_session_start', + MCP_TOOL_CALLED: 'mcp_tool_called', + MIGRATE_RUN: 'migrate_run', + ONBOARDING_AUTO_SETUP_STARTED: 'onboarding_auto_setup_started', + ONBOARDING_COMPLETED: 'onboarding_completed', + QUERY_COMPLETED: 'query_completed', + REVIEW_APPROVED: 'review_approved', + REVIEW_REJECTED: 'review_rejected', + REVIEW_TOGGLED: 'review_toggled', + SETTING_CHANGED: 'setting_changed', + SETTING_RESET: 'setting_reset', + SOURCE_ADDED: 'source_added', + SOURCE_REMOVED: 'source_removed', + SPACE_SWITCHED: 'space_switched', + SWARM_ONBOARDED: 'swarm_onboarded', + SWARM_QUERY_COMPLETED: 'swarm_query_completed', + SWARM_STORE_COMPLETED: 'swarm_store_completed', + TASK_COMPLETED: 'task_completed', + TASK_CREATED: 'task_created', + TASK_FAILED: 'task_failed', + VC_BRANCHED: 'vc_branched', + VC_CHECKED_OUT: 'vc_checked_out', + VC_CLONED: 'vc_cloned', + VC_COMMIT: 'vc_commit', + VC_DISCARDED: 'vc_discarded', + VC_FETCHED: 'vc_fetched', + VC_INIT: 'vc_init', + VC_MERGED: 'vc_merged', + VC_PULLED: 'vc_pulled', + VC_PUSHED: 'vc_pushed', + VC_REMOTE_CHANGED: 'vc_remote_changed', + VC_RESET_EXECUTED: 'vc_reset_executed', + WEBUI_SESSION_ENDED: 'webui_session_ended', + WEBUI_SESSION_STARTED: 'webui_session_started', + WORKTREE_ADDED: 'worktree_added', + WORKTREE_REMOVED: 'worktree_removed', +} as const + +export type AnalyticsEventName = (typeof AnalyticsEventNames)[keyof typeof AnalyticsEventNames] diff --git a/src/shared/analytics/events/analytics-disabled.ts b/src/shared/analytics/events/analytics-disabled.ts new file mode 100644 index 000000000..f45a6ee10 --- /dev/null +++ b/src/shared/analytics/events/analytics-disabled.ts @@ -0,0 +1,13 @@ +import {z} from 'zod' + +/** + * Per-event schema for `analytics_disabled`. + * + * No properties. The emit captures the moment the user opts out via + * `brv settings set analytics.share false`; identity is stamped by the per-event identity + * resolver and `client_kind` by the super-property layer. The disable + * action itself is the entire signal. + */ +export const AnalyticsDisabledSchema = z.object({}).strict() + +export type AnalyticsDisabledProps = z.infer diff --git a/src/shared/analytics/events/auth-login.ts b/src/shared/analytics/events/auth-login.ts new file mode 100644 index 000000000..be1194282 --- /dev/null +++ b/src/shared/analytics/events/auth-login.ts @@ -0,0 +1,30 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `auth_login`. + * + * Carries the lifecycle outcome (`success` | `failure`) so a single event + * name covers both the OAuth success terminal and the failure terminal. + * `failure_kind` is a coarse enum-like tag (free string today but emitters + * should pick from a small discrete vocabulary like `'callback_timeout'`, + * `'token_exchange'`, `'user_fetch'`, `'token_save'`, `'state_reload'`, + * `'unknown'`) so downstream consumers can aggregate failure modes without + * raw error-message PII risk. Never put `error_message`-style free text in + * `failure_kind`. + * + * Identity (the new authenticated `user_id` on success, anonymous on + * failure) is stamped on the per-event identity by the resolver; + * `client_kind` is stamped on the envelope by the super-property layer. + * + * M6's Mixpanel forwarding pipeline keys its server-side + * `alias(deviceId -> User.id)` off `{name: auth_login, outcome: success}`. + */ +export const AuthLoginSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type AuthLoginProps = z.infer diff --git a/src/shared/analytics/events/auth-logout.ts b/src/shared/analytics/events/auth-logout.ts new file mode 100644 index 000000000..695a87b8e --- /dev/null +++ b/src/shared/analytics/events/auth-logout.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `auth_logout`. + * + * Carries the lifecycle outcome (`success` | `failure`). On success the + * emit fires BEFORE `tokenStore.clear()` so the per-event identity is the + * logged-in user that just opted out. On failure (e.g. `tokenStore.clear()` + * threw, `disconnectByteRoverProvider()` threw, or `loadToken()` threw) + * the identity is whatever the in-memory store still holds — which is the + * logged-in user because identity rebinding hasn't happened yet. + * + * `failure_kind` should be a coarse enum-like tag (e.g. `'token_clear'`, + * `'provider_disconnect'`, `'state_reload'`, `'unknown'`) — see + * `auth-login.ts` for the rationale. Never put raw error messages here. + */ +export const AuthLogoutSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type AuthLogoutProps = z.infer diff --git a/src/shared/analytics/events/brv-init.ts b/src/shared/analytics/events/brv-init.ts new file mode 100644 index 000000000..746ce33fb --- /dev/null +++ b/src/shared/analytics/events/brv-init.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `brv_init`. + * + * Activation-funnel entry: `brv init` ran on a project (success or failure). + * `project_path_hash` = sha256 of the absolute project path (raw paths + * are forbidden); `had_existing_brv_dir` separates "first-touch" from + * "re-init" funnels. `outcome` covers both terminals; `failure_kind` is a + * coarse tag — never a raw error message. + */ +export const BrvInitSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + had_existing_brv_dir: z.boolean(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type BrvInitProps = z.infer diff --git a/src/shared/analytics/events/cli-invocation.ts b/src/shared/analytics/events/cli-invocation.ts new file mode 100644 index 000000000..7fc3cf8c6 --- /dev/null +++ b/src/shared/analytics/events/cli-invocation.ts @@ -0,0 +1,11 @@ +/** + * Per-event schema for `cli_invocation`. + * + * Source of truth lives in `src/shared/analytics/cli-metadata-schema.ts` + * — the same shape doubles as the `cli_metadata` block embedded in every + * client-originated request schema (M13). Re-exported here under + * `CliInvocationSchema` / `CliInvocationProps` so the analytics catalog + * at `events/index.ts` keeps its existing import path. + */ +export {CliMetadataSchema as CliInvocationSchema} from '../cli-metadata-schema.js' +export type {CliMetadata as CliInvocationProps} from '../cli-metadata-schema.js' diff --git a/src/shared/analytics/events/connector-installed.ts b/src/shared/analytics/events/connector-installed.ts new file mode 100644 index 000000000..68c8480dc --- /dev/null +++ b/src/shared/analytics/events/connector-installed.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `connector_installed`. + * + * `connector_id` identifies which connector (e.g. `claude-code`, `cursor`, + * `amp`); `agent_target` is the external coding-agent surface it installs + * into. `outcome` covers both terminals; `failure_kind` is a coarse tag. + */ +export const ConnectorInstalledSchema = z + .object({ + agent_target: z.string().min(1), + connector_id: z.string().min(1), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type ConnectorInstalledProps = z.infer diff --git a/src/shared/analytics/events/content-migrated.ts b/src/shared/analytics/events/content-migrated.ts new file mode 100644 index 000000000..bddaa27a0 --- /dev/null +++ b/src/shared/analytics/events/content-migrated.ts @@ -0,0 +1,48 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `content_migrated`. + * + * Admin-op content migration between scopes (e.g. moving curated knowledge + * between sources, spaces, or projects). Distinct from `migrate_run` + * (ENG-3008 / `migrate-run.ts`) which covers the `brv migrate` MD→HTML + * one-shot — that operation lives in `MigrateHandler` and its semantics + * are file-format-conversion, not scope-aware data movement. + * + * `source_kind` / `target_kind` are short enum strings naming the + * abstract scope on each side (e.g. `'local'`, `'space'`, `'shared'`). + * Kept as `z.string().min(1).max(64)` rather than a closed enum so future + * scopes can plug in without a schema migration; the producer is + * responsible for taxonomizing. + * + * Per the M15.1 outcome taxonomy: `outcome: 'success' | 'failure'`, + * `failure_kind` populated only on failure. Counts are optional so a + * failure path that surfaces before counts are known still emits a + * well-formed event. + * + * SCHEMA-ONLY REGISTRATION TODAY: no daemon-handler emit site exists in + * this codebase yet. The producer will land alongside the admin op when + * its handler is built. See ENG-2770 for the precedent. + */ +const failureKindSchema = z.string().min(1).max(64).optional() +const countSchema = z.number().int().nonnegative().optional() + +export const ContentMigratedSchema = z + .object({ + /** True when the run was a no-write dry run. */ + dry_run: z.boolean().optional(), + /** Counts — optional because failure can surface before they're computed. */ + duration_ms: z.number().int().nonnegative().optional(), + failed: countSchema, + failure_kind: failureKindSchema, + migrated: countSchema, + outcome: z.enum(['success', 'failure']), + skipped: countSchema, + /** Abstract scope identifiers (e.g. 'local', 'space', 'shared'). Producer-taxonomized. */ + source_kind: z.string().min(1).max(64), + target_kind: z.string().min(1).max(64), + }) + .strict() + +export type ContentMigratedProps = z.infer diff --git a/src/shared/analytics/events/context-tree-file-edited.ts b/src/shared/analytics/events/context-tree-file-edited.ts new file mode 100644 index 000000000..a736472bb --- /dev/null +++ b/src/shared/analytics/events/context-tree-file-edited.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `context_tree_file_edited`. + * + * Direct WebUI edit of a context-tree file. `byte_delta` is only known on + * success — optional. `file_relative_path_hash` is computed at request time + * and stays valid for both outcomes. + */ +export const ContextTreeFileEditedSchema = z + .object({ + byte_delta: z.number().int().optional(), + failure_kind: z.string().min(1).max(64).optional(), + file_relative_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ContextTreeFileEditedProps = z.infer diff --git a/src/shared/analytics/events/curate-operation-applied.ts b/src/shared/analytics/events/curate-operation-applied.ts new file mode 100644 index 000000000..dc8fc39de --- /dev/null +++ b/src/shared/analytics/events/curate-operation-applied.ts @@ -0,0 +1,40 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `curate_operation_applied`. + * + * Emitted by the daemon's `AnalyticsHook` (M12.2) once per successful curate + * operation. Each operation carries the affected file's project-relative + * path, its knowledge-tree address, review/impact metadata, and (M12.3) the + * file's current-state frontmatter values for tags / keywords / related. + * + * Review tightening (M14 follow-up): + * - `absolute_path` → `relative_path` for privacy + portability across hosts + * - `keywords` / `tags` are now required arrays (default empty) so consumers + * don't have to special-case the "field absent" shape + * - `related` stays optional and absent on DELETE / read-failure (file is + * gone or unreadable, no related-link source to harvest from) + */ +export const CurateOperationAppliedSchema = z + .object({ + confidence: z.enum(['high', 'low']).optional(), + impact: z.enum(['high', 'low']).optional(), + keywords: z.array(z.string().max(256)).max(50), + knowledge_path: z.string().min(1), + needs_review: z.boolean(), + operation_type: z.enum(['ADD', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT']), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + // TODO(M15.x): harmonise with the sibling `query_completed.read_paths_ + // _with_metadata[].related_paths` structured shape — current asymmetry + // forces consumers to special-case parsing `related` between the two + // events. Restructuring is its own ticket (consumer migration concern). + related: z.array(z.string().max(256)).max(50).optional(), + relative_path: z.string().min(1), + tags: z.array(z.string().max(256)).max(50), + task_id: z.string().min(1), + }) + .strict() + +export type CurateOperationAppliedProps = z.infer diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts new file mode 100644 index 000000000..2f12309e2 --- /dev/null +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -0,0 +1,49 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per-event schema for `curate_run_completed`. + * + * Emitted by the daemon's `AnalyticsHook` (M12.2) at curate task terminal + * states (completed / partial / cancelled / error). Carries per-task + * operation counters so PMs can aggregate curate volume + outcome over time. + * + * M14.2 migrated `task_type` from a literal ['curate', 'curate-folder'] + * enum to the canonical `TASK_TYPE_VALUES` tuple so v4.0 tool-mode types + * (curate-tool-mode) round-trip the wire boundary. The hook is expected + * to only emit this event for curate flavors; the schema no longer + * structurally enforces that and trusts the caller. + */ +export const CurateRunCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + operations_added: z.number().int().nonnegative(), + operations_deleted: z.number().int().nonnegative(), + operations_failed: z.number().int().nonnegative(), + operations_merged: z.number().int().nonnegative(), + operations_updated: z.number().int().nonnegative(), + outcome: z.enum(['completed', 'partial', 'cancelled', 'error']), + pending_review_count: z.number().int().nonnegative(), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + /** + * Active Context Hub space ID for the project, when connected. Sourced + * from `.brv/config.json#spaceId` at emit time. Omitted (not empty + * string) when the project is standalone or the lookup fails — never + * blocks an emit on space metadata. + */ + space_id: z.string().min(1).max(64).optional(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + /** + * Active team ID for the project, when connected. Independent of + * `space_id` — a project can have a team without a space (intermediate + * onboarding state). Same opaque-ID shape and emit semantics. + */ + team_id: z.string().min(1).max(64).optional(), + }) + .strict() + +export type CurateRunCompletedProps = z.infer diff --git a/src/shared/analytics/events/daemon-reset-executed.ts b/src/shared/analytics/events/daemon-reset-executed.ts new file mode 100644 index 000000000..38a96be97 --- /dev/null +++ b/src/shared/analytics/events/daemon-reset-executed.ts @@ -0,0 +1,17 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `daemon_reset_executed`. + * + * `brv reset` escape hatch. + */ +export const DaemonResetExecutedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + reset_scope: z.enum(['project', 'global']), + }) + .strict() + +export type DaemonResetExecutedProps = z.infer diff --git a/src/shared/analytics/events/daemon-start.ts b/src/shared/analytics/events/daemon-start.ts new file mode 100644 index 000000000..f212e63da --- /dev/null +++ b/src/shared/analytics/events/daemon-start.ts @@ -0,0 +1,14 @@ + +import {z} from 'zod' + +/** + * Per-event schema for `daemon_start`. + * + * No properties: every cold-start dimension worth tracking is already + * stamped as a super property on every event (cli_version, os, + * node_version, environment, device_id) by the SuperPropertiesResolver. + * Strict mode rejects accidental property bleed. + */ +export const DaemonStartSchema = z.object({}).strict() + +export type DaemonStartProps = z.infer diff --git a/src/shared/analytics/events/hub-package-installed.ts b/src/shared/analytics/events/hub-package-installed.ts new file mode 100644 index 000000000..5baa19eca --- /dev/null +++ b/src/shared/analytics/events/hub-package-installed.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `hub_package_installed`. + * + * Context Hub install (npm-style). `package_identifier` is the + * `/` identifier known at request time; `version_pin` is the + * resolved version pin (only known on success — optional). `outcome` + * covers both terminals; `failure_kind` is a coarse tag. + */ +export const HubPackageInstalledSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + package_identifier: z.string().min(1), + version_pin: z.string().min(1).optional(), + }) + .strict() + +export type HubPackageInstalledProps = z.infer diff --git a/src/shared/analytics/events/hub-registry-added.ts b/src/shared/analytics/events/hub-registry-added.ts new file mode 100644 index 000000000..3faee4cfd --- /dev/null +++ b/src/shared/analytics/events/hub-registry-added.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `hub_registry_added`. + * + * Adds a registry source to the Context Hub config. `registry_kind` + * classifies the registry type; `is_default` flags whether it was added + * as the default. `is_default` is optional because the current handler + * request shape doesn't carry it — emitters that don't know the value + * MAY omit. `outcome` covers both terminals. + */ +export const HubRegistryAddedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + is_default: z.boolean().optional(), + outcome: z.enum(['success', 'failure']), + registry_kind: z.string().min(1), + }) + .strict() + +export type HubRegistryAddedProps = z.infer diff --git a/src/shared/analytics/events/hub-registry-removed.ts b/src/shared/analytics/events/hub-registry-removed.ts new file mode 100644 index 000000000..ecde37086 --- /dev/null +++ b/src/shared/analytics/events/hub-registry-removed.ts @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `hub_registry_removed`. + */ +export const HubRegistryRemovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + registry_kind: z.string().min(1), + }) + .strict() + +export type HubRegistryRemovedProps = z.infer diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts new file mode 100644 index 000000000..1507cfab6 --- /dev/null +++ b/src/shared/analytics/events/index.ts @@ -0,0 +1,209 @@ +import type {AnalyticsEventName} from '../event-names.js' + +import {AnalyticsEventNames} from '../event-names.js' +import {type AnalyticsDisabledProps, AnalyticsDisabledSchema} from './analytics-disabled.js' +import {type AuthLoginProps, AuthLoginSchema} from './auth-login.js' +import {type AuthLogoutProps, AuthLogoutSchema} from './auth-logout.js' +import {type BrvInitProps, BrvInitSchema} from './brv-init.js' +import {type CliInvocationProps, CliInvocationSchema} from './cli-invocation.js' +import {type ConnectorInstalledProps, ConnectorInstalledSchema} from './connector-installed.js' +import {type ContentMigratedProps, ContentMigratedSchema} from './content-migrated.js' +import {type ContextTreeFileEditedProps, ContextTreeFileEditedSchema} from './context-tree-file-edited.js' +import {type CurateOperationAppliedProps, CurateOperationAppliedSchema} from './curate-operation-applied.js' +import {type CurateRunCompletedProps, CurateRunCompletedSchema} from './curate-run-completed.js' +import {type DaemonResetExecutedProps, DaemonResetExecutedSchema} from './daemon-reset-executed.js' +import {type DaemonStartProps, DaemonStartSchema} from './daemon-start.js' +import {type HubPackageInstalledProps, HubPackageInstalledSchema} from './hub-package-installed.js' +import {type HubRegistryAddedProps, HubRegistryAddedSchema} from './hub-registry-added.js' +import {type HubRegistryRemovedProps, HubRegistryRemovedSchema} from './hub-registry-removed.js' +import {type McpSessionEndedProps, McpSessionEndedSchema} from './mcp-session-ended.js' +import {type McpSessionStartProps, McpSessionStartSchema} from './mcp-session-start.js' +import {type McpToolCalledProps, McpToolCalledSchema} from './mcp-tool-called.js' +import {type MigrateRunProps, MigrateRunSchema} from './migrate-run.js' +import {type OnboardingAutoSetupStartedProps, OnboardingAutoSetupStartedSchema} from './onboarding-auto-setup-started.js' +import {type OnboardingCompletedProps, OnboardingCompletedSchema} from './onboarding-completed.js' +import {type QueryCompletedProps, QueryCompletedSchema} from './query-completed.js' +import {type ReviewApprovedProps, ReviewApprovedSchema} from './review-approved.js' +import {type ReviewRejectedProps, ReviewRejectedSchema} from './review-rejected.js' +import {type ReviewToggledProps, ReviewToggledSchema} from './review-toggled.js' +import {type SettingChangedProps, SettingChangedSchema} from './setting-changed.js' +import {type SettingResetProps, SettingResetSchema} from './setting-reset.js' +import {type SourceAddedProps, SourceAddedSchema} from './source-added.js' +import {type SourceRemovedProps, SourceRemovedSchema} from './source-removed.js' +import {type SpaceSwitchedProps, SpaceSwitchedSchema} from './space-switched.js' +import {type SwarmOnboardedProps, SwarmOnboardedSchema} from './swarm-onboarded.js' +import {type SwarmQueryCompletedProps, SwarmQueryCompletedSchema} from './swarm-query-completed.js' +import {type SwarmStoreCompletedProps, SwarmStoreCompletedSchema} from './swarm-store-completed.js' +import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' +import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' +import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' +import {type VcBranchedProps, VcBranchedSchema} from './vc-branched.js' +import {type VcCheckedOutProps, VcCheckedOutSchema} from './vc-checked-out.js' +import {type VcClonedProps, VcClonedSchema} from './vc-cloned.js' +import {type VcCommitProps, VcCommitSchema} from './vc-commit.js' +import {type VcDiscardedProps, VcDiscardedSchema} from './vc-discarded.js' +import {type VcFetchedProps, VcFetchedSchema} from './vc-fetched.js' +import {type VcInitProps, VcInitSchema} from './vc-init.js' +import {type VcMergedProps, VcMergedSchema} from './vc-merged.js' +import {type VcPulledProps, VcPulledSchema} from './vc-pulled.js' +import {type VcPushedProps, VcPushedSchema} from './vc-pushed.js' +import {type VcRemoteChangedProps, VcRemoteChangedSchema} from './vc-remote-changed.js' +import {type VcResetExecutedProps, VcResetExecutedSchema} from './vc-reset-executed.js' +import {type WebuiSessionEndedProps, WebuiSessionEndedSchema} from './webui-session-ended.js' +import {type WebuiSessionStartedProps, WebuiSessionStartedSchema} from './webui-session-started.js' +import {type WorktreeAddedProps, WorktreeAddedSchema} from './worktree-added.js' +import {type WorktreeRemovedProps, WorktreeRemovedSchema} from './worktree-removed.js' + +/** + * Registry of every shipped event schema, keyed by wire name. Used by: + * - The privacy fixture, which walks every entry and asserts no field + * name appears on the forbidden PII list. + * - Per-event validation at the wire boundary (`AnalyticsHandler`). + * + * Adding a new event requires three steps: + * 1. New constant in `../event-names.ts`. + * 2. New per-event file in this folder. + * 3. New entry in both `ALL_EVENT_SCHEMAS` and `AnyAnalyticsEvent` below. + * + * Some entries are deferred scaffolding for upcoming milestones — they have + * schemas but no emitter today. The wire-side handler dispatch must still + * cover them (drop with Zod parse) once an emitter lands. + */ +export const ALL_EVENT_SCHEMAS = { + [AnalyticsEventNames.ANALYTICS_DISABLED]: AnalyticsDisabledSchema, + [AnalyticsEventNames.AUTH_LOGIN]: AuthLoginSchema, + [AnalyticsEventNames.AUTH_LOGOUT]: AuthLogoutSchema, + [AnalyticsEventNames.BRV_INIT]: BrvInitSchema, + [AnalyticsEventNames.CLI_INVOCATION]: CliInvocationSchema, + [AnalyticsEventNames.CONNECTOR_INSTALLED]: ConnectorInstalledSchema, + [AnalyticsEventNames.CONTENT_MIGRATED]: ContentMigratedSchema, + [AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED]: ContextTreeFileEditedSchema, + [AnalyticsEventNames.CURATE_OPERATION_APPLIED]: CurateOperationAppliedSchema, + [AnalyticsEventNames.CURATE_RUN_COMPLETED]: CurateRunCompletedSchema, + [AnalyticsEventNames.DAEMON_RESET_EXECUTED]: DaemonResetExecutedSchema, + [AnalyticsEventNames.DAEMON_START]: DaemonStartSchema, + [AnalyticsEventNames.HUB_PACKAGE_INSTALLED]: HubPackageInstalledSchema, + [AnalyticsEventNames.HUB_REGISTRY_ADDED]: HubRegistryAddedSchema, + [AnalyticsEventNames.HUB_REGISTRY_REMOVED]: HubRegistryRemovedSchema, + [AnalyticsEventNames.MCP_SESSION_ENDED]: McpSessionEndedSchema, + [AnalyticsEventNames.MCP_SESSION_START]: McpSessionStartSchema, + [AnalyticsEventNames.MCP_TOOL_CALLED]: McpToolCalledSchema, + [AnalyticsEventNames.MIGRATE_RUN]: MigrateRunSchema, + [AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED]: OnboardingAutoSetupStartedSchema, + [AnalyticsEventNames.ONBOARDING_COMPLETED]: OnboardingCompletedSchema, + [AnalyticsEventNames.QUERY_COMPLETED]: QueryCompletedSchema, + [AnalyticsEventNames.REVIEW_APPROVED]: ReviewApprovedSchema, + [AnalyticsEventNames.REVIEW_REJECTED]: ReviewRejectedSchema, + [AnalyticsEventNames.REVIEW_TOGGLED]: ReviewToggledSchema, + [AnalyticsEventNames.SETTING_CHANGED]: SettingChangedSchema, + [AnalyticsEventNames.SETTING_RESET]: SettingResetSchema, + [AnalyticsEventNames.SOURCE_ADDED]: SourceAddedSchema, + [AnalyticsEventNames.SOURCE_REMOVED]: SourceRemovedSchema, + [AnalyticsEventNames.SPACE_SWITCHED]: SpaceSwitchedSchema, + [AnalyticsEventNames.SWARM_ONBOARDED]: SwarmOnboardedSchema, + [AnalyticsEventNames.SWARM_QUERY_COMPLETED]: SwarmQueryCompletedSchema, + [AnalyticsEventNames.SWARM_STORE_COMPLETED]: SwarmStoreCompletedSchema, + [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, + [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, + [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, + [AnalyticsEventNames.VC_BRANCHED]: VcBranchedSchema, + [AnalyticsEventNames.VC_CHECKED_OUT]: VcCheckedOutSchema, + [AnalyticsEventNames.VC_CLONED]: VcClonedSchema, + [AnalyticsEventNames.VC_COMMIT]: VcCommitSchema, + [AnalyticsEventNames.VC_DISCARDED]: VcDiscardedSchema, + [AnalyticsEventNames.VC_FETCHED]: VcFetchedSchema, + [AnalyticsEventNames.VC_INIT]: VcInitSchema, + [AnalyticsEventNames.VC_MERGED]: VcMergedSchema, + [AnalyticsEventNames.VC_PULLED]: VcPulledSchema, + [AnalyticsEventNames.VC_PUSHED]: VcPushedSchema, + [AnalyticsEventNames.VC_REMOTE_CHANGED]: VcRemoteChangedSchema, + [AnalyticsEventNames.VC_RESET_EXECUTED]: VcResetExecutedSchema, + [AnalyticsEventNames.WEBUI_SESSION_ENDED]: WebuiSessionEndedSchema, + [AnalyticsEventNames.WEBUI_SESSION_STARTED]: WebuiSessionStartedSchema, + [AnalyticsEventNames.WORKTREE_ADDED]: WorktreeAddedSchema, + [AnalyticsEventNames.WORKTREE_REMOVED]: WorktreeRemovedSchema, +} as const + +/** + * Discriminated union over every event in the catalog. A consumer can + * destructure {name, properties} and TypeScript will narrow `properties` + * against the matching per-event type. + */ +export type AnyAnalyticsEvent = + | {name: typeof AnalyticsEventNames.ANALYTICS_DISABLED; properties: AnalyticsDisabledProps} + | {name: typeof AnalyticsEventNames.AUTH_LOGIN; properties: AuthLoginProps} + | {name: typeof AnalyticsEventNames.AUTH_LOGOUT; properties: AuthLogoutProps} + | {name: typeof AnalyticsEventNames.BRV_INIT; properties: BrvInitProps} + | {name: typeof AnalyticsEventNames.CLI_INVOCATION; properties: CliInvocationProps} + | {name: typeof AnalyticsEventNames.CONNECTOR_INSTALLED; properties: ConnectorInstalledProps} + | {name: typeof AnalyticsEventNames.CONTENT_MIGRATED; properties: ContentMigratedProps} + | {name: typeof AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED; properties: ContextTreeFileEditedProps} + | {name: typeof AnalyticsEventNames.CURATE_OPERATION_APPLIED; properties: CurateOperationAppliedProps} + | {name: typeof AnalyticsEventNames.CURATE_RUN_COMPLETED; properties: CurateRunCompletedProps} + | {name: typeof AnalyticsEventNames.DAEMON_RESET_EXECUTED; properties: DaemonResetExecutedProps} + | {name: typeof AnalyticsEventNames.DAEMON_START; properties: DaemonStartProps} + | {name: typeof AnalyticsEventNames.HUB_PACKAGE_INSTALLED; properties: HubPackageInstalledProps} + | {name: typeof AnalyticsEventNames.HUB_REGISTRY_ADDED; properties: HubRegistryAddedProps} + | {name: typeof AnalyticsEventNames.HUB_REGISTRY_REMOVED; properties: HubRegistryRemovedProps} + | {name: typeof AnalyticsEventNames.MCP_SESSION_ENDED; properties: McpSessionEndedProps} + | {name: typeof AnalyticsEventNames.MCP_SESSION_START; properties: McpSessionStartProps} + | {name: typeof AnalyticsEventNames.MCP_TOOL_CALLED; properties: McpToolCalledProps} + | {name: typeof AnalyticsEventNames.MIGRATE_RUN; properties: MigrateRunProps} + | {name: typeof AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED; properties: OnboardingAutoSetupStartedProps} + | {name: typeof AnalyticsEventNames.ONBOARDING_COMPLETED; properties: OnboardingCompletedProps} + | {name: typeof AnalyticsEventNames.QUERY_COMPLETED; properties: QueryCompletedProps} + | {name: typeof AnalyticsEventNames.REVIEW_APPROVED; properties: ReviewApprovedProps} + | {name: typeof AnalyticsEventNames.REVIEW_REJECTED; properties: ReviewRejectedProps} + | {name: typeof AnalyticsEventNames.REVIEW_TOGGLED; properties: ReviewToggledProps} + | {name: typeof AnalyticsEventNames.SETTING_CHANGED; properties: SettingChangedProps} + | {name: typeof AnalyticsEventNames.SETTING_RESET; properties: SettingResetProps} + | {name: typeof AnalyticsEventNames.SOURCE_ADDED; properties: SourceAddedProps} + | {name: typeof AnalyticsEventNames.SOURCE_REMOVED; properties: SourceRemovedProps} + | {name: typeof AnalyticsEventNames.SPACE_SWITCHED; properties: SpaceSwitchedProps} + | {name: typeof AnalyticsEventNames.SWARM_ONBOARDED; properties: SwarmOnboardedProps} + | {name: typeof AnalyticsEventNames.SWARM_QUERY_COMPLETED; properties: SwarmQueryCompletedProps} + | {name: typeof AnalyticsEventNames.SWARM_STORE_COMPLETED; properties: SwarmStoreCompletedProps} + | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} + | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} + | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} + | {name: typeof AnalyticsEventNames.VC_BRANCHED; properties: VcBranchedProps} + | {name: typeof AnalyticsEventNames.VC_CHECKED_OUT; properties: VcCheckedOutProps} + | {name: typeof AnalyticsEventNames.VC_CLONED; properties: VcClonedProps} + | {name: typeof AnalyticsEventNames.VC_COMMIT; properties: VcCommitProps} + | {name: typeof AnalyticsEventNames.VC_DISCARDED; properties: VcDiscardedProps} + | {name: typeof AnalyticsEventNames.VC_FETCHED; properties: VcFetchedProps} + | {name: typeof AnalyticsEventNames.VC_INIT; properties: VcInitProps} + | {name: typeof AnalyticsEventNames.VC_MERGED; properties: VcMergedProps} + | {name: typeof AnalyticsEventNames.VC_PULLED; properties: VcPulledProps} + | {name: typeof AnalyticsEventNames.VC_PUSHED; properties: VcPushedProps} + | {name: typeof AnalyticsEventNames.VC_REMOTE_CHANGED; properties: VcRemoteChangedProps} + | {name: typeof AnalyticsEventNames.VC_RESET_EXECUTED; properties: VcResetExecutedProps} + | {name: typeof AnalyticsEventNames.WEBUI_SESSION_ENDED; properties: WebuiSessionEndedProps} + | {name: typeof AnalyticsEventNames.WEBUI_SESSION_STARTED; properties: WebuiSessionStartedProps} + | {name: typeof AnalyticsEventNames.WORKTREE_ADDED; properties: WorktreeAddedProps} + | {name: typeof AnalyticsEventNames.WORKTREE_REMOVED; properties: WorktreeRemovedProps} + +/** + * Type-derived properties for a given event name. Magic-string typos + * (e.g. `'daemon_starts'`) and wrong-shape payloads (e.g. `tool_name` + * on `daemon_start`) become compile errors instead of runtime drops. + */ +export type PropsForEvent = Extract['properties'] + +/** + * If the event has no required properties (e.g. `daemon_start`), the + * `properties` argument is optional. Otherwise it is required. Implemented + * via a rest tuple so the call site stays ergonomic. + */ +export type PropsArg = keyof PropsForEvent extends never + ? [properties?: PropsForEvent] + : [properties: PropsForEvent] + +/** + * Runtime guard: narrows an unknown string to a known `AnalyticsEventName`. + * Used by the wire-side handler to reject events that have no schema + * before forwarding to the typed daemon client. + */ +export function isAnalyticsEventName(value: unknown): value is AnalyticsEventName { + return typeof value === 'string' && value in ALL_EVENT_SCHEMAS +} diff --git a/src/shared/analytics/events/mcp-session-ended.ts b/src/shared/analytics/events/mcp-session-ended.ts new file mode 100644 index 000000000..5af0cda3c --- /dev/null +++ b/src/shared/analytics/events/mcp-session-ended.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `mcp_session_ended`. + * + * Mirrors `webui_session_ended` (M15.5) for the MCP transport. Fires when + * a client of type `mcp` disconnects from the daemon (or is orphan-ended + * on reconnect). Pairs with a prior `mcp_session_start` via + * `started_at_unix_ms`. `client_name` is the IDE product name captured + * during the MCP `oninitialized` handshake. + * + * IMPORTANT: NO `session_id` field — that name is on `forbidden-field-names.ts` + * and would be runtime-redacted. `started_at_unix_ms` (the connectedAt + * Date.now() value) serves as the join key. + */ +export const McpSessionEndedSchema = z + .object({ + client_name: z.string().min(1), + session_duration_ms: z.number().int().nonnegative(), + started_at_unix_ms: z.number().int().nonnegative(), + }) + .strict() + +export type McpSessionEndedProps = z.infer diff --git a/src/shared/analytics/events/mcp-session-start.ts b/src/shared/analytics/events/mcp-session-start.ts new file mode 100644 index 000000000..cda85a98a --- /dev/null +++ b/src/shared/analytics/events/mcp-session-start.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `mcp_session_start`. + * + * `client_name` is the IDE's self-reported product name (e.g. "Cursor", + * "Claude Code"), captured via the MCP `oninitialized` handshake. It is + * never a person's name; the field is named for the MCP client identity, + * not user identity. + */ +export const McpSessionStartSchema = z + .object({ + client_name: z.string().min(1), + }) + .strict() + +export type McpSessionStartProps = z.infer diff --git a/src/shared/analytics/events/mcp-tool-called.ts b/src/shared/analytics/events/mcp-tool-called.ts new file mode 100644 index 000000000..6356d0338 --- /dev/null +++ b/src/shared/analytics/events/mcp-tool-called.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `mcp_tool_called`. + * + * Captures the funnel for IDE-driven tool invocations (`brv-query`, + * `brv-curate`). User-supplied tool arguments (the query text, curate goal, + * file paths) are NEVER captured — only universal metadata. + */ +export const McpToolCalledSchema = z + .object({ + client_name: z.string().min(1), + duration_ms: z.number().int().nonnegative(), + success: z.boolean(), + tool_name: z.enum(['brv-query', 'brv-curate']), + }) + .strict() + +export type McpToolCalledProps = z.infer diff --git a/src/shared/analytics/events/migrate-run.ts b/src/shared/analytics/events/migrate-run.ts new file mode 100644 index 000000000..da0d10827 --- /dev/null +++ b/src/shared/analytics/events/migrate-run.ts @@ -0,0 +1,62 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `migrate_run`. + * + * Emitted by the daemon's `MigrateHandler` once per `brv migrate` invocation + * (forward or rollback, success or failure). Counts mirror the orchestrator's + * `MigrationReport` and `RollbackReport` shapes: + * + * forward: migrated / archived / skipped / failed come from `summary.*` + * rollback: restored / deleted_html / preserved_html come from `restored`, + * `deletedHtml.length`, `preservedHtml.length` + * + * The wire schema is a discriminated union on `mode` so a `rollback` payload + * structurally cannot carry forward-only counters (`migrated`, `archived`, + * `skipped`, `failed`) and vice versa. Downstream warehouse queries can rely + * on the schema to enforce per-mode counter separation rather than filtering + * by `mode` after the fact. + * + * Per-mode counters stay optional because failure paths surface before counts + * can be computed. + * + * `failure_kind` is populated only when `outcome === 'failure'`. Free-form + * short string (caller-classified, e.g. `archive_exists`, `no_archive`, + * `unknown`) so the producer can taxonomize without a schema migration. + */ + +const failureKindSchema = z.string().min(1).max(64).optional() +const countSchema = z.number().int().nonnegative().optional() + +const MigrateRunForwardSchema = z + .object({ + archived: countSchema, + dry_run: z.boolean(), + failed: countSchema, + failure_kind: failureKindSchema, + migrated: countSchema, + mode: z.literal('forward'), + outcome: z.enum(['success', 'failure']), + skipped: countSchema, + }) + .strict() + +const MigrateRunRollbackSchema = z + .object({ + deleted_html: countSchema, + dry_run: z.boolean(), + failure_kind: failureKindSchema, + mode: z.literal('rollback'), + outcome: z.enum(['success', 'failure']), + preserved_html: countSchema, + restored: countSchema, + }) + .strict() + +export const MigrateRunSchema = z.discriminatedUnion('mode', [ + MigrateRunForwardSchema, + MigrateRunRollbackSchema, +]) + +export type MigrateRunProps = z.infer diff --git a/src/shared/analytics/events/onboarding-auto-setup-started.ts b/src/shared/analytics/events/onboarding-auto-setup-started.ts new file mode 100644 index 000000000..bb7567ba3 --- /dev/null +++ b/src/shared/analytics/events/onboarding-auto-setup-started.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `onboarding_auto_setup_started`. + * + * The onboarding flow began an auto-setup pass. `mode` discriminates entry + * modes (e.g. `auto`, `manual`). `outcome` covers the start-attempt + * terminal — `success` if the auto-setup actually kicked off, `failure` if + * the start path errored. + */ +export const OnboardingAutoSetupStartedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + mode: z.string().min(1), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type OnboardingAutoSetupStartedProps = z.infer diff --git a/src/shared/analytics/events/onboarding-completed.ts b/src/shared/analytics/events/onboarding-completed.ts new file mode 100644 index 000000000..9c5d82706 --- /dev/null +++ b/src/shared/analytics/events/onboarding-completed.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `onboarding_completed`. + * + * Activation funnel terminal. `steps_completed_count` is only meaningful + * on success and so is optional. `outcome` covers both terminals. + */ +export const OnboardingCompletedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + steps_completed_count: z.number().int().nonnegative().optional(), + }) + .strict() + +export type OnboardingCompletedProps = z.infer diff --git a/src/shared/analytics/events/query-completed.ts b/src/shared/analytics/events/query-completed.ts new file mode 100644 index 000000000..2ab860981 --- /dev/null +++ b/src/shared/analytics/events/query-completed.ts @@ -0,0 +1,89 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per related-path metadata. Each related entry is a project-relative + * knowledge path captured from a read file's frontmatter `related` list, + * carrying its own keywords / tags so PMs can see what the linked-from + * topics actually cover. + * + * keywords / tags default to `[]` when the related file isn't on disk or + * when analytics is disabled (no enrichment read happens). The shape is + * structured here so a later FU can fill keywords/tags without a wire + * format change. + */ +const RelatedPathWithMetadataSchema = z + .object({ + keywords: z.array(z.string().max(256)).max(50), + relative_path: z.string().min(1), + tags: z.array(z.string().max(256)).max(50), + }) + .strict() + +/** + * Per-file structure inside `query_completed.read_paths_with_metadata`. + * + * Review tightening (M14 follow-up): + * - `absolute_path` → `relative_path` for privacy + portability + * - `keywords` / `tags` are now required arrays (default `[]`) so the + * "field absent" wire shape goes away + * - flat `related: string[]` → structured `related_paths: [{relative_path, + * keywords, tags}]` so each linked topic carries its own metadata + */ +const ReadPathWithMetadataSchema = z + .object({ + keywords: z.array(z.string().max(256)).max(50), + related_paths: z.array(RelatedPathWithMetadataSchema).max(50), + relative_path: z.string().min(1), + tags: z.array(z.string().max(256)).max(50), + }) + .strict() + +/** + * Per-event schema for `query_completed`. + * + * Emitted by the daemon's `AnalyticsHook` (M12.2) at query task terminal + * states (completed / cancelled / error). Carries duration, retrieval + * tier hit, doc counts, and (M12.3) the per-file structure for the top-N + * (max 10) files the agent read during the query. + * + * M14.2 migrated `task_type` from `z.literal('query')` to the canonical + * `TASK_TYPE_VALUES` tuple so v4.0 tool-mode types (query-tool-mode) + * round-trip the wire boundary. The hook is expected to only emit this + * event for query flavors; the schema no longer structurally enforces + * that and trusts the caller. + */ +export const QueryCompletedSchema = z + .object({ + cache_hit: z.boolean(), + duration_ms: z.number().int().nonnegative(), + matched_doc_count: z.number().int().nonnegative(), + outcome: z.enum(['completed', 'cancelled', 'error']), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + read_doc_count: z.number().int().nonnegative(), + read_paths_with_metadata: z.array(ReadPathWithMetadataSchema).max(10).optional(), + read_tool_call_count: z.number().int().nonnegative(), + search_call_count: z.number().int().nonnegative(), + /** + * Active Context Hub space ID for the project, when connected. Sourced + * from `.brv/config.json#spaceId` at emit time. Omitted (not empty + * string) when the project is standalone or the lookup fails — never + * blocks an emit on space metadata. + */ + space_id: z.string().min(1).max(64).optional(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + /** + * Active team ID for the project, when connected. Independent of + * `space_id` — a project can have a team without a space (intermediate + * onboarding state). Same opaque-ID shape and emit semantics. + */ + team_id: z.string().min(1).max(64).optional(), + tier: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), + }) + .strict() + +export type QueryCompletedProps = z.infer diff --git a/src/shared/analytics/events/review-approved.ts b/src/shared/analytics/events/review-approved.ts new file mode 100644 index 000000000..090280290 --- /dev/null +++ b/src/shared/analytics/events/review-approved.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `review_approved`. + * + * HITL review: user approved a pending curate operation. + * `operation_kind` is the curate operation discriminator. + */ +export const ReviewApprovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + operation_kind: z.string().min(1), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ReviewApprovedProps = z.infer diff --git a/src/shared/analytics/events/review-rejected.ts b/src/shared/analytics/events/review-rejected.ts new file mode 100644 index 000000000..a931c91d3 --- /dev/null +++ b/src/shared/analytics/events/review-rejected.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `review_rejected`. + * + * Mirrors `review_approved` shape so downstream consumers can compute + * per-operation approve/reject ratios. + */ +export const ReviewRejectedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + operation_kind: z.string().min(1), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ReviewRejectedProps = z.infer diff --git a/src/shared/analytics/events/review-toggled.ts b/src/shared/analytics/events/review-toggled.ts new file mode 100644 index 000000000..440a80697 --- /dev/null +++ b/src/shared/analytics/events/review-toggled.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `review_toggled`. + * + * User toggled HITL review on or off (`brv review enable` / `disable`). + * `new_state` is only meaningful on success — optional. + */ +export const ReviewToggledSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + new_state: z.enum(['enabled', 'disabled']).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ReviewToggledProps = z.infer diff --git a/src/shared/analytics/events/setting-changed.ts b/src/shared/analytics/events/setting-changed.ts new file mode 100644 index 000000000..1a03f1eb2 --- /dev/null +++ b/src/shared/analytics/events/setting-changed.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `setting_changed`. + * + * Carries `setting_key` only — NEVER the raw value, because a future + * string-typed setting could carry paths or secrets. `value_kind` + * discriminates the type bucket; `value_changed_from_default` is only + * computable on success (optional). `outcome` covers both terminals. + */ +export const SettingChangedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + setting_key: z.string().min(1), + value_changed_from_default: z.boolean().optional(), + value_kind: z.enum(['integer', 'boolean', 'readonly-info']), + }) + .strict() + +export type SettingChangedProps = z.infer diff --git a/src/shared/analytics/events/setting-reset.ts b/src/shared/analytics/events/setting-reset.ts new file mode 100644 index 000000000..c83eb8dce --- /dev/null +++ b/src/shared/analytics/events/setting-reset.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `setting_reset`. + * + * Symmetric with `setting_changed`; does NOT carry the value. + */ +export const SettingResetSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + setting_key: z.string().min(1), + value_kind: z.enum(['integer', 'boolean', 'readonly-info']), + }) + .strict() + +export type SettingResetProps = z.infer diff --git a/src/shared/analytics/events/source-added.ts b/src/shared/analytics/events/source-added.ts new file mode 100644 index 000000000..6217e88e6 --- /dev/null +++ b/src/shared/analytics/events/source-added.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `source_added`. + * + * Adds another project's context tree as a read-only knowledge source. + * `source_origin_hash` is only stable on success (raw path forbidden) — + * optional. + */ +export const SourceAddedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + source_origin_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + }) + .strict() + +export type SourceAddedProps = z.infer diff --git a/src/shared/analytics/events/source-removed.ts b/src/shared/analytics/events/source-removed.ts new file mode 100644 index 000000000..01604cb99 --- /dev/null +++ b/src/shared/analytics/events/source-removed.ts @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `source_removed`. + */ +export const SourceRemovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type SourceRemovedProps = z.infer diff --git a/src/shared/analytics/events/space-switched.ts b/src/shared/analytics/events/space-switched.ts new file mode 100644 index 000000000..c077e1d33 --- /dev/null +++ b/src/shared/analytics/events/space-switched.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `space_switched`. + * + * Active Context Hub space changed. `to_space_id` only set on success + * (the switch landed). `from_space_id` is always known at request time. + */ +export const SpaceSwitchedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + from_space_id: z.string().min(1), + outcome: z.enum(['success', 'failure']), + to_space_id: z.string().min(1).optional(), + }) + .strict() + +export type SpaceSwitchedProps = z.infer diff --git a/src/shared/analytics/events/swarm-onboarded.ts b/src/shared/analytics/events/swarm-onboarded.ts new file mode 100644 index 000000000..a573148d5 --- /dev/null +++ b/src/shared/analytics/events/swarm-onboarded.ts @@ -0,0 +1,41 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `swarm_onboarded`. + * + * Activation entry point for `brv swarm onboard` — fires when the wizard + * completes (success path) or aborts (failure path). Swarm counterpart + * to M15.2's brv-init / onboarding-completed activation events. + * + * `swarm_kind` is a short producer-taxonomized string (e.g. `'new'` when + * the user scaffolded a fresh config, `'joined'` when they pointed at an + * existing swarm). Kept as `z.string().min(1).max(64)` so future flows + * plug in without a schema migration. + * + * `member_count` captures the active-provider count from the resulting + * swarm config (e.g. `byterover`, `obsidian`, `gbrain`). Optional — + * failure paths may surface before the count is computed. + * + * SCHEMA-ONLY REGISTRATION TODAY: the swarm onboard surface lives in the + * agent process (`src/agent/infra/swarm/wizard/swarm-wizard.ts`), not in + * a daemon transport handler. The producer requires either a new daemon + * handler that the CLI command calls, or a synthetic-emit pattern (cf. + * M17). That wiring is deferred to a follow-up. See ENG-2770 for the + * schema-only precedent. + */ +const failureKindSchema = z.string().min(1).max(64).optional() + +export const SwarmOnboardedSchema = z + .object({ + duration_ms: z.number().int().nonnegative().optional(), + failure_kind: failureKindSchema, + /** Number of active providers in the resulting swarm config. */ + member_count: z.number().int().nonnegative().optional(), + outcome: z.enum(['success', 'failure']), + /** Onboarding flow taxonomy (e.g. 'new', 'joined'). Producer-taxonomized. */ + swarm_kind: z.string().min(1).max(64).optional(), + }) + .strict() + +export type SwarmOnboardedProps = z.infer diff --git a/src/shared/analytics/events/swarm-query-completed.ts b/src/shared/analytics/events/swarm-query-completed.ts new file mode 100644 index 000000000..5d78880c3 --- /dev/null +++ b/src/shared/analytics/events/swarm-query-completed.ts @@ -0,0 +1,50 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `swarm_query_completed`. + * + * Swarm counterpart to `query_completed` (ENG-2770 / M12) — fires once + * per `brv swarm query` invocation OR per `swarm_query` LLM tool call, + * covering the read loop across federated memory providers (byterover, + * obsidian, gbrain, …) coordinated by `swarm-coordinator.ts`. + * + * `swarm_scope` is a short producer-taxonomized string describing which + * provider set the query spanned: `'local'` (current project only), + * `'remote'` (external providers only), or `'mixed'` (both). Kept as + * `z.string().min(1).max(64)` so future scope kinds plug in without a + * schema migration; the producer is responsible for the taxonomy. + * + * `tags` / `keywords` / `related` mirror the M12.3 frontmatter-harvest + * precedent — when the query fuses results from a Memory-Wiki adapter + * that carries those fields, surface them so the funnel stays comparable + * to the in-project `query_completed` events. + * + * Per the M15.1 outcome taxonomy: `outcome: 'success' | 'failure'`, + * `failure_kind` populated only on failure. `duration_ms` is required + * because the coordinator always knows it by terminal time. + * + * SCHEMA-ONLY REGISTRATION TODAY: the swarm query surface lives in the + * agent process (`src/agent/infra/swarm/swarm-coordinator.ts`), not in + * a daemon transport handler. Emit wiring deferred per plan flag #2. + */ +const failureKindSchema = z.string().min(1).max(64).optional() +const stringArraySchema = z.array(z.string().max(256)).max(50).optional() + +export const SwarmQueryCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + failure_kind: failureKindSchema, + /** Optional frontmatter harvest (M12.3 parity) for the top-N fused results. */ + keywords: stringArraySchema, + outcome: z.enum(['success', 'failure']), + related: stringArraySchema, + /** Number of fused results returned to the caller. */ + result_count: z.number().int().nonnegative().optional(), + /** Provider-set kind ('local' | 'remote' | 'mixed' | …). Producer-taxonomized. */ + swarm_scope: z.string().min(1).max(64).optional(), + tags: stringArraySchema, + }) + .strict() + +export type SwarmQueryCompletedProps = z.infer diff --git a/src/shared/analytics/events/swarm-store-completed.ts b/src/shared/analytics/events/swarm-store-completed.ts new file mode 100644 index 000000000..5c0ce4b5c --- /dev/null +++ b/src/shared/analytics/events/swarm-store-completed.ts @@ -0,0 +1,53 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `swarm_store_completed`. + * + * Swarm counterpart to `curate_operation_applied` / `curate_run_completed` + * (ENG-2770 / M12). Fires once per `brv swarm curate` invocation OR per + * `swarm_store` LLM tool call — covering the write loop that fans curated + * knowledge out to federated memory providers via `swarm-coordinator.store()`. + * + * `operation` is a short producer-taxonomized string naming the write + * shape (`'add'`, `'update'`, `'merge'`, …). Kept as `z.string().min(1).max(64)` + * so future operation kinds plug in without a schema migration. + * + * Counters mirror the M12 curate-aggregation idiom: + * - `stored` — providers that accepted a new write + * - `updated` — providers that updated an existing entry + * - `skipped` — providers that no-op'd (already up to date, declined, etc.) + * + * `tags` / `keywords` / `related` mirror the M12.3 frontmatter-harvest + * precedent for parity with the in-project curate events. + * + * Per the M15.1 outcome taxonomy: `outcome: 'success' | 'failure'`, + * `failure_kind` populated only on failure. `duration_ms` is required. + * + * SCHEMA-ONLY REGISTRATION TODAY: the swarm store surface lives in the + * agent process (`src/agent/infra/swarm/swarm-coordinator.ts` + + * `src/agent/infra/swarm/adapters/memory-wiki-adapter.ts`), not in a + * daemon transport handler. Emit wiring deferred per plan flag #2. + */ +const failureKindSchema = z.string().min(1).max(64).optional() +const countSchema = z.number().int().nonnegative().optional() +const stringArraySchema = z.array(z.string().max(256)).max(50).optional() + +export const SwarmStoreCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + failure_kind: failureKindSchema, + keywords: stringArraySchema, + /** Write-operation kind ('add' | 'update' | 'merge' | …). Producer-taxonomized. */ + operation: z.string().min(1).max(64), + outcome: z.enum(['success', 'failure']), + related: stringArraySchema, + /** Per-outcome provider counts; optional because failure can surface before they're computed. */ + skipped: countSchema, + stored: countSchema, + tags: stringArraySchema, + updated: countSchema, + }) + .strict() + +export type SwarmStoreCompletedProps = z.infer diff --git a/src/shared/analytics/events/task-completed.ts b/src/shared/analytics/events/task-completed.ts new file mode 100644 index 000000000..d84b33adb --- /dev/null +++ b/src/shared/analytics/events/task-completed.ts @@ -0,0 +1,23 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per-event schema for `task_completed`. + * + * Successful task termination. The `result` payload (LLM output, search + * results, curated content) is NEVER captured here — it is forbidden by + * the privacy fixture. + */ +export const TaskCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + }) + .strict() + +export type TaskCompletedProps = z.infer diff --git a/src/shared/analytics/events/task-created.ts b/src/shared/analytics/events/task-created.ts new file mode 100644 index 000000000..b291186bf --- /dev/null +++ b/src/shared/analytics/events/task-created.ts @@ -0,0 +1,33 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per-event schema for `task_created`. + * + * Funnel entry point. `has_files` / `has_folder` are booleans only — the + * actual paths are forbidden, never enter the analytics payload. + * + * `task_id` is an internal UUID generated by the daemon; it is not user PII + * and exists solely to correlate `task_created` with later + * `task_completed` / `task_failed` events. + */ +export const TaskCreatedSchema = z + .object({ + has_files: z.boolean(), + has_folder: z.boolean(), + /** + * M17 follow-up: project-scoped join key, matching the convention every + * other handler-emitted event uses (vc-*, review-*, source-*, worktree-*, + * brv-init, context-tree-file-edited, webui-session-*). Optional because + * TaskInfo.projectPath is `?: string` — when the router does not resolve + * a project context the emit omits the field rather than fabricating one. + */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + }) + .strict() + +export type TaskCreatedProps = z.infer diff --git a/src/shared/analytics/events/task-failed.ts b/src/shared/analytics/events/task-failed.ts new file mode 100644 index 000000000..6015761a5 --- /dev/null +++ b/src/shared/analytics/events/task-failed.ts @@ -0,0 +1,44 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Coarse-vocabulary classification of why a task ended in a non-success + * state. Strictly enumerated so consumers can group failures without + * having to parse raw error messages. Every value is ≤64 chars and + * carries no PII. + * + * - `cancelled` — onTaskCancelled lifecycle path; user-initiated abort. + * - `timeout` — error message indicates the agent / LLM exceeded a budget. + * - `agent_error` — error message indicates a recognised agent-side fault + * (provider rejection, tool failure, schema reject, etc.). + * - `unknown` — anything else; the hook MUST default here rather than + * widening the enum on a hunch. + */ +export const FailureKindValues = ['cancelled', 'timeout', 'agent_error', 'unknown'] as const +export type FailureKind = (typeof FailureKindValues)[number] + +/** + * Per-event schema for `task_failed`. + * + * Error path. The error message and stack trace are intentionally NOT + * captured here: they may contain file paths, secrets, or user content. + * Strict mode rejects any attempt to add `error_message` / `stack` later. + * + * `failure_kind` (M15.6) is a coarse-vocabulary tag the daemon classifies + * the error into. Producers MUST emit one of the canonical values; the + * hook never forwards raw `error.message` text under any field name. + */ +export const TaskFailedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + failure_kind: z.enum(FailureKindValues), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + }) + .strict() + +export type TaskFailedProps = z.infer diff --git a/src/shared/analytics/events/vc-branched.ts b/src/shared/analytics/events/vc-branched.ts new file mode 100644 index 000000000..025a419e2 --- /dev/null +++ b/src/shared/analytics/events/vc-branched.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_branched`. + * + * `from_default_branch` only meaningful on success. + */ +export const VcBranchedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + from_default_branch: z.boolean().optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcBranchedProps = z.infer diff --git a/src/shared/analytics/events/vc-checked-out.ts b/src/shared/analytics/events/vc-checked-out.ts new file mode 100644 index 000000000..6259d8901 --- /dev/null +++ b/src/shared/analytics/events/vc-checked-out.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_checked_out`. + * + * `branch_kind = 'existing' | 'created'`. Only meaningful on success. + */ +export const VcCheckedOutSchema = z + .object({ + branch_kind: z.enum(['existing', 'created']).optional(), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcCheckedOutProps = z.infer diff --git a/src/shared/analytics/events/vc-cloned.ts b/src/shared/analytics/events/vc-cloned.ts new file mode 100644 index 000000000..4c763dc8c --- /dev/null +++ b/src/shared/analytics/events/vc-cloned.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_cloned`. + * + * First-touch event for a new project. `project_path_hash` only stable on + * success (the directory exists). `remote_kind` is known at request time. + */ +export const VcClonedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcClonedProps = z.infer diff --git a/src/shared/analytics/events/vc-commit.ts b/src/shared/analytics/events/vc-commit.ts new file mode 100644 index 000000000..498ede445 --- /dev/null +++ b/src/shared/analytics/events/vc-commit.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_commit`. + * + * `files_changed_count` is only known on success (post-commit). `had_message` + * is known at request time. `client_kind` super-property segments CLI-typed + * commits vs WebUI Changes-tab clicks. + */ +export const VcCommitSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + files_changed_count: z.number().int().nonnegative().optional(), + had_message: z.boolean(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcCommitProps = z.infer diff --git a/src/shared/analytics/events/vc-discarded.ts b/src/shared/analytics/events/vc-discarded.ts new file mode 100644 index 000000000..914e0c44c --- /dev/null +++ b/src/shared/analytics/events/vc-discarded.ts @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_discarded`. + */ +export const VcDiscardedSchema = z + .object({ + discard_scope: z.enum(['file', 'all']), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcDiscardedProps = z.infer diff --git a/src/shared/analytics/events/vc-fetched.ts b/src/shared/analytics/events/vc-fetched.ts new file mode 100644 index 000000000..646891719 --- /dev/null +++ b/src/shared/analytics/events/vc-fetched.ts @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_fetched`. + */ +export const VcFetchedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcFetchedProps = z.infer diff --git a/src/shared/analytics/events/vc-init.ts b/src/shared/analytics/events/vc-init.ts new file mode 100644 index 000000000..735ab522d --- /dev/null +++ b/src/shared/analytics/events/vc-init.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_init`. + * + * `had_existing_git_dir` separates fresh-init from convert-existing. + */ +export const VcInitSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + had_existing_git_dir: z.boolean(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcInitProps = z.infer diff --git a/src/shared/analytics/events/vc-merged.ts b/src/shared/analytics/events/vc-merged.ts new file mode 100644 index 000000000..4c74e734a --- /dev/null +++ b/src/shared/analytics/events/vc-merged.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_merged`. + * + * `had_fast_forward` only known on success. + */ +export const VcMergedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + had_fast_forward: z.boolean().optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcMergedProps = z.infer diff --git a/src/shared/analytics/events/vc-pulled.ts b/src/shared/analytics/events/vc-pulled.ts new file mode 100644 index 000000000..b53dba46e --- /dev/null +++ b/src/shared/analytics/events/vc-pulled.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_pulled`. + * + * `branch_name_hash` = sha256 of the branch name (raw branch names may + * carry user-identifying tokens at organizations using `/` + * conventions). + */ +export const VcPulledSchema = z + .object({ + branch_name_hash: z.string().regex(/^[0-9a-f]{64}$/), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcPulledProps = z.infer diff --git a/src/shared/analytics/events/vc-pushed.ts b/src/shared/analytics/events/vc-pushed.ts new file mode 100644 index 000000000..0a84ce11b --- /dev/null +++ b/src/shared/analytics/events/vc-pushed.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_pushed`. + * + * See `vc-pulled.ts` for the rationale on `branch_name_hash`. + */ +export const VcPushedSchema = z + .object({ + branch_name_hash: z.string().regex(/^[0-9a-f]{64}$/), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcPushedProps = z.infer diff --git a/src/shared/analytics/events/vc-remote-changed.ts b/src/shared/analytics/events/vc-remote-changed.ts new file mode 100644 index 000000000..ab4b0010d --- /dev/null +++ b/src/shared/analytics/events/vc-remote-changed.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_remote_changed`. + * + * Collapses the 3 `brv vc remote` subcommands via `change_kind`. + */ +export const VcRemoteChangedSchema = z + .object({ + change_kind: z.enum(['added', 'removed', 'url_set']), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcRemoteChangedProps = z.infer diff --git a/src/shared/analytics/events/vc-reset-executed.ts b/src/shared/analytics/events/vc-reset-executed.ts new file mode 100644 index 000000000..6a6f26d31 --- /dev/null +++ b/src/shared/analytics/events/vc-reset-executed.ts @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_reset_executed`. + */ +export const VcResetExecutedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + reset_mode: z.enum(['soft', 'mixed', 'hard']), + }) + .strict() + +export type VcResetExecutedProps = z.infer diff --git a/src/shared/analytics/events/webui-session-ended.ts b/src/shared/analytics/events/webui-session-ended.ts new file mode 100644 index 000000000..1a07136b5 --- /dev/null +++ b/src/shared/analytics/events/webui-session-ended.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `webui_session_ended`. + * + * Matches a `webui_session_started` row via `started_at_unix_ms`. + * `session_duration_ms` is computed daemon-side from `ClientInfo.connectedAt`. + * + * IMPORTANT: NO `session_id` field — that name is on `forbidden-field-names.ts` + * and would be runtime-redacted by `redactRecord`. The `started_at_unix_ms` + * Date.now() value at register time serves as the join key instead. + */ +export const WebuiSessionEndedSchema = z + .object({ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + session_duration_ms: z.number().int().nonnegative(), + started_at_unix_ms: z.number().int().nonnegative(), + }) + .strict() + +export type WebuiSessionEndedProps = z.infer diff --git a/src/shared/analytics/events/webui-session-started.ts b/src/shared/analytics/events/webui-session-started.ts new file mode 100644 index 000000000..5643a110e --- /dev/null +++ b/src/shared/analytics/events/webui-session-started.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `webui_session_started`. + * + * Fires when a browser dashboard connects to the daemon over Socket.IO. + * `started_at_unix_ms` (Date.now()) is the join key with the matching + * `webui_session_ended` row. + * + * IMPORTANT: NO `session_id` field — see `webui-session-ended.ts`. + */ +export const WebuiSessionStartedSchema = z + .object({ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + started_at_unix_ms: z.number().int().nonnegative(), + }) + .strict() + +export type WebuiSessionStartedProps = z.infer diff --git a/src/shared/analytics/events/worktree-added.ts b/src/shared/analytics/events/worktree-added.ts new file mode 100644 index 000000000..685b9c39a --- /dev/null +++ b/src/shared/analytics/events/worktree-added.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `worktree_added`. + * + * `worktree_kind` classifies the worktree model (e.g. `pointer`, `real`). + * Only known on success. + */ +export const WorktreeAddedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + worktree_kind: z.string().min(1).optional(), + }) + .strict() + +export type WorktreeAddedProps = z.infer diff --git a/src/shared/analytics/events/worktree-removed.ts b/src/shared/analytics/events/worktree-removed.ts new file mode 100644 index 000000000..923ca4112 --- /dev/null +++ b/src/shared/analytics/events/worktree-removed.ts @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `worktree_removed`. + */ +export const WorktreeRemovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type WorktreeRemovedProps = z.infer diff --git a/src/shared/analytics/forbidden-field-names.ts b/src/shared/analytics/forbidden-field-names.ts new file mode 100644 index 000000000..62b4570a5 --- /dev/null +++ b/src/shared/analytics/forbidden-field-names.ts @@ -0,0 +1,89 @@ +import type {StoredAnalyticsRecord} from './stored-record.js' + +/** + * Field names that MUST NOT appear inside an analytics event's `properties` + * record. Originally extracted from the M2.8 privacy fixture + * (test/unit/shared/analytics/privacy-fixture.test.ts) which uses this set + * to assert that no per-event Zod schema declares any of these keys. + * + * M11.2 promotes the list to a runtime constant so the daemon's + * analytics-list-handler can apply defense-in-depth redaction on read. + * + * Categories: secrets/credentials, PII identifiers, filesystem paths, + * user content, error fields that may carry paths/secrets, network + * identifiers. + */ +export const FORBIDDEN_FIELD_NAMES: ReadonlySet = new Set([ + // Secrets / credentials + 'access_token', + // PII identifiers + 'address', + 'api_key', + // Filesystem paths + 'argv', + 'auth_header', + 'auth_token', + // User content + 'content', + 'cookie', + 'credential', + 'cwd', + 'display_name', + 'email', + // Errors that may carry paths/secrets/content + 'error_message', + 'file_path', + 'first_name', + 'folder_path', + 'goal', + 'home_dir', + // Network identifiers + 'hostname', + 'ip', + 'last_name', + 'mac', + 'output', + 'password', + 'path', + 'phone', + 'phone_number', + 'project_path', + 'prompt', + 'query', + 'result', + 'secret', + 'session_id', + 'session_token', + 'ssn', + 'stack', + 'token', + 'username', + 'worktree_root', +]) + +/** + * Defense-in-depth redaction for `record.properties`. Drops any top-level + * key whose name appears on `FORBIDDEN_FIELD_NAMES`; preserves all other + * keys verbatim. + * + * `record.identity` is INTENTIONALLY left untouched. The identity block + * (`device_id`, `email`, `name`, `user_id`) is the always-stamped + * super-property — `email` there is a legit identifier for the local + * user, not a content leak. The forbidden list applies only to + * event-specific property schemas, not to the identity envelope. + * + * Returns a fresh shallow clone — the caller can mutate the result + * without affecting the input. Only top-level `properties` keys are + * inspected; nested objects are passed through untouched (the M2.8 + * schema layer is responsible for preventing nested forbidden names + * from ever being declared). + */ +export function redactRecord(record: StoredAnalyticsRecord): StoredAnalyticsRecord { + const safeProperties: Record = {} + for (const [key, value] of Object.entries(record.properties)) { + if (FORBIDDEN_FIELD_NAMES.has(key)) continue + safeProperties[key] = value + } + + return {...record, properties: safeProperties} +} diff --git a/src/shared/analytics/stored-record.ts b/src/shared/analytics/stored-record.ts new file mode 100644 index 000000000..a9cd69eb1 --- /dev/null +++ b/src/shared/analytics/stored-record.ts @@ -0,0 +1,121 @@ +/* eslint-disable camelcase */ +import {formatISO} from 'date-fns' +import {z} from 'zod' + +/** + * Maximum number of send attempts before a record terminates as `'failed'`. + * + * Consumed inside `JsonlAnalyticsStore.updateStatus` (M9.2): on a `'failed'` + * update the store increments `attempts`; the row only transitions to + * terminal `status='failed'` when `attempts >= MAX_ATTEMPTS`. Otherwise it + * stays at `status='pending'` so the next flush cycle re-attempts it. + * + * M10.3 verifies the composition end-to-end. + */ +export const MAX_ATTEMPTS = 3 + +/** + * Local-only status enum for daemon-side persistence. Never serialized to + * the wire — see `toWireEvent` for the strip path. + */ +const StoredStatusSchema = z.enum(['pending', 'sent', 'failed']) + +export type StoredStatus = z.infer + +/** + * Wire-format identity, snake_case per the analytics spec. `device_id` is + * always present; the rest are optional and only stamped when the user is + * authenticated. Kept here so the stored-record schema is self-contained + * and importable from any layer (no cross-layer reach into server/). + */ +const IdentityWireSchema = z.object({ + device_id: z.string().refine((s) => s.trim().length > 0, { + message: 'device_id must be non-empty', + }), + email: z.string().optional(), + name: z.string().optional(), + user_id: z.string().optional(), +}) + +/** + * A local-only stored record. Extends the wire-format analytics event + * shape with three daemon-internal fields: + * + * - `id`: stable per-row identifier (uuid v4) for `updateStatus` mutations + * - `status`: `'pending' | 'sent' | 'failed'` + * - `attempts`: number of send attempts (0..MAX_ATTEMPTS) + * + * The wire format that goes to the backend (M3+) stays unchanged — these + * extra fields are local metadata and NEVER leave the daemon. `toWireEvent` + * is the strip helper M4's HTTP sender uses when shipping a batch. + */ +export const StoredAnalyticsRecordSchema = z.object({ + attempts: z.number().int().min(0), + // Wire-bound ISO 8601 string with timezone designator. Optional so pre- + // upgrade rows on disk (which carry only the numeric `timestamp`) continue + // to parse. `toWireEvent` derives a value from `timestamp` when this is + // absent so the wire payload is always complete. + created_at: z.string().datetime({offset: true}).optional(), + id: z.string().min(1), + identity: IdentityWireSchema, + name: z.string(), + properties: z.record(z.string(), z.unknown()), + status: StoredStatusSchema, + // Local-only sort key (epoch ms). Required: see `JsonlAnalyticsStore.list` + // which sorts on numeric subtraction. NEVER emitted on the wire. + timestamp: z.number(), +}) + +/** + * `Readonly<>` wrapper aligns with the rest of the analytics domain + * (`Identity`, `AnalyticsEvent`, `AnalyticsEventWithIdentity` are all + * `Readonly<>`). A stored row is a frozen-in-time snapshot of the disk + * state; M9.2 mutates by spread + rewrite, never in-place. + */ +export type StoredAnalyticsRecord = Readonly> + +/** + * The wire-shape view of a stored record (no `id` / `status` / `attempts` / + * `timestamp`). Structurally identical to the daemon-side + * `AnalyticsEventWithIdentity` type; declared here as a `Pick` so this + * module has no dependency on server-side domain code and can be imported + * by `shared/`. + * + * `created_at` is required on the wire even though it is optional on + * `StoredAnalyticsRecord` (pre-upgrade rows derive it from `timestamp` at + * send time via `toWireEvent`). + */ +export type WireAnalyticsEvent = Pick & { + created_at: string +} + +/** + * Strips local-only fields (`id`, `status`, `attempts`, `timestamp`) from a + * stored record and returns the wire-format event shape that can be shipped + * to the backend. M4's HTTP sender uses this on the way out; M9.3 + * (in-process) and M11.2 (over transport) both keep the local fields for + * their own purposes. + * + * Emits `created_at` (ISO 8601 with offset). For pre-upgrade rows that lack + * a stored `created_at`, derives one from the numeric `timestamp` so the + * wire payload is always complete. The UTC instant is exact, but the offset + * in the derived string reflects the daemon's local timezone at send time, + * not the user's timezone at original capture. + * + * Note: `formatISO` (date-fns) emits second precision and drops the + * millisecond component, so a derived `created_at` reparses to an instant + * up to 999ms earlier than the stored `timestamp`. The backend's UTC + * normalization tolerates this; the local `timestamp` remains the + * authoritative sub-second sort key on disk. + * + * The backend stores the normalized UTC instant, so both the offset drift + * and the second-precision truncation are informational only. + */ +export function toWireEvent(record: StoredAnalyticsRecord): WireAnalyticsEvent { + return { + created_at: record.created_at ?? formatISO(new Date(record.timestamp)), + identity: record.identity, + name: record.name, + properties: record.properties, + } +} diff --git a/src/shared/analytics/task-types.ts b/src/shared/analytics/task-types.ts new file mode 100644 index 000000000..7a1058af8 --- /dev/null +++ b/src/shared/analytics/task-types.ts @@ -0,0 +1,51 @@ + + +/** + * Canonical wire-format values for `task_type` on task_* analytics events. + * Mirrors the daemon's `TaskInfo.type` union (see + * server/core/domain/transport/task-info.ts). + * + * Adding a new daemon task type REQUIRES adding it here so per-event schemas + * accept it; otherwise the analytics hook will silently emit an event that + * fails wire-side validation. + */ +export const TaskTypes = { + CURATE: 'curate', + CURATE_FOLDER: 'curate-folder', + CURATE_TOOL_MODE: 'curate-tool-mode', + DREAM: 'dream', + DREAM_FINALIZE: 'dream-finalize', + DREAM_SCAN: 'dream-scan', + QUERY: 'query', + QUERY_TOOL_MODE: 'query-tool-mode', + SEARCH: 'search', + /** + * Drift sentinel — emitted by `AnalyticsHook.toAnalyticsTaskType` when the + * daemon dispatches a type that isn't enumerated above. Lives in the + * canonical vocabulary so the wire-side `z.enum(TASK_TYPE_VALUES)` accepts + * the row at the backend instead of dropping it. The daemon-side + * `processLog` warning is the actual signal — `'unknown'` on the wire is + * the breadcrumb the backend can group on. + */ + UNKNOWN: 'unknown', +} as const + +export type TaskType = (typeof TaskTypes)[keyof typeof TaskTypes] + +/** + * Tuple form of TaskTypes used as a runtime list (e.g. `z.enum(TASK_TYPE_VALUES)`). + * Single source of truth: per-event schemas import this instead of redeclaring + * the literal array, so adding a new daemon task type is a one-place change. + */ +export const TASK_TYPE_VALUES = [ + TaskTypes.CURATE, + TaskTypes.CURATE_FOLDER, + TaskTypes.CURATE_TOOL_MODE, + TaskTypes.DREAM, + TaskTypes.DREAM_FINALIZE, + TaskTypes.DREAM_SCAN, + TaskTypes.QUERY, + TaskTypes.QUERY_TOOL_MODE, + TaskTypes.SEARCH, + TaskTypes.UNKNOWN, +] as const diff --git a/src/shared/assets/analytics-disclosure.md b/src/shared/assets/analytics-disclosure.md new file mode 100644 index 000000000..4e34fef22 --- /dev/null +++ b/src/shared/assets/analytics-disclosure.md @@ -0,0 +1,36 @@ +# ByteRover CLI Analytics Disclosure + +Lorem ipsum placeholder copy. PM and legal will replace each section's +body before the M1 release. Section headers are load-bearing for tests +and must remain stable. + +## What is collected + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Event names and +super properties (`device_id`, `cli_version`, `os`, `node_version`, +`environment`) are recorded. No content of your queries, files, or +memory is collected. + +## Which surfaces are tracked + +Lorem ipsum: TUI, oclif commands, MCP server, local web UI, and agent +processes. + +## Where it goes + +Lorem ipsum dolor sit amet. Events flow to the ByteRover Analytics +Service, with Mixpanel acting as a sub-processor. + +## Cross-device alias + +Lorem ipsum: if you log in on this device, prior anonymous activity here +is permanently linked to your account. + +## How to disable + +Lorem ipsum: run `brv settings set analytics.share false` at any time to +stop collection. + +## Privacy policy + +https://byterover.dev/privacy diff --git a/src/shared/constants/privacy.ts b/src/shared/constants/privacy.ts new file mode 100644 index 000000000..d070455da --- /dev/null +++ b/src/shared/constants/privacy.ts @@ -0,0 +1,6 @@ +/** + * Public privacy policy URL for ByteRover CLI analytics. + * Placeholder until M1.5 lands the canonical docs page; reviewers should + * update this constant when the byterover-docs URL is finalized. + */ +export const PRIVACY_POLICY_URL = 'https://byterover.dev/privacy' diff --git a/src/shared/constants/settings-keys.ts b/src/shared/constants/settings-keys.ts new file mode 100644 index 000000000..0ddbea229 --- /dev/null +++ b/src/shared/constants/settings-keys.ts @@ -0,0 +1,13 @@ +/** + * Cross-cutting settings key constants. The full registry of writable and + * readonly-info descriptors lives at + * `src/server/core/domain/entities/settings.ts` and may only be imported + * from `server/` and `agent/` callers. + * + * This module re-exposes the subset of key names that other layers (TUI, + * WebUI, oclif) need to refer to without crossing the `tui -> server` + * import boundary. Each constant is the literal wire key — a rename here + * is a typecheck error at every consuming site. + */ + +export const ANALYTICS_ENABLED_KEY = 'analytics.share' as const diff --git a/src/shared/transport/events/agent-events.ts b/src/shared/transport/events/agent-events.ts index 08d7a44b0..0af2ed6a7 100644 --- a/src/shared/transport/events/agent-events.ts +++ b/src/shared/transport/events/agent-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const AgentEvents = { CONNECTED: 'agent:connected', DISCONNECTED: 'agent:disconnected', @@ -10,6 +13,7 @@ export const AgentEvents = { } as const export interface AgentRestartRequest { + cli_metadata?: CliMetadata reason: string } @@ -18,6 +22,7 @@ export interface AgentRestartResponse { } export interface AgentNewSessionRequest { + cli_metadata?: CliMetadata reason?: string } diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts new file mode 100644 index 000000000..141e6c413 --- /dev/null +++ b/src/shared/transport/events/analytics-events.ts @@ -0,0 +1,93 @@ +import {z} from 'zod' + +import {CliRequestBaseSchema} from '../../analytics/cli-metadata-schema.js' +import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js' + +export const AnalyticsEvents = { + LIST: 'analytics:list', + STATUS: 'analytics:status', + TRACK: 'analytics:track', +} as const + +/** + * M4.6 `analytics:status` response. Surfaces operational metrics for + * `brv settings get analytics.status`: enabled flag (from GlobalConfig), client + * runtime state (last-flush timestamp, JSONL pending depth, dropped + * count), backoff state (M4.5 policy + derived reachability label), + * and the analytics endpoint URL. + * + * `lastFlushAt` is epoch milliseconds (`undefined` when the daemon has + * not shipped anything yet this session → status renders "never"). + * + * `endpoint` is the resolved `BRV_ANALYTICS_BASE_URL` or the literal + * `"(not configured)"` placeholder; when not configured, `backoff.state` + * is forced to `"unreachable"` regardless of `consecutiveFailures`. + * + * `state: 'rate_limited'` (M5.4 / ENG-2658) is distinct from `unreachable`: + * the backend is up but throttling us (429 / 503 edge backstop), so on-call + * should wait out the server-supplied delay rather than chase an outage. + */ +export const AnalyticsStatusResponseSchema = z.object({ + backoff: z.object({ + consecutiveFailures: z.number().int().min(0), + nextDelayMs: z.number().int().min(0), + state: z.enum(['healthy', 'degraded', 'rate_limited', 'unreachable']), + }), + droppedCount: z.number().int().min(0), + enabled: z.boolean(), + endpoint: z.string().min(1), + lastFlushAt: z.number().int().min(0).optional(), + queueDepth: z.number().int().min(0), +}) + +export type AnalyticsStatusResponse = z.infer + +/** + * Wire-level validation for `analytics:track` payloads. Identity and super + * properties are stamped daemon-side on receipt; per-event property schemas + * (cli_invocation, mcp_tool_called, ...) are designed in M2.8. + * + * Single source of truth for the wire shape: callers (emitAnalytics) and the + * daemon handler (AnalyticsHandler) both use the inferred type so they cannot + * drift independently. + */ +export const AnalyticsTrackPayloadSchema = z.object({ + event: z.string().min(1), + properties: z.record(z.string(), z.unknown()).optional(), +}) + +export type AnalyticsTrackPayload = z.infer + +/** + * Request schema for `analytics:list` (M11.1). Pagination is offset/limit; + * filters by `eventName` (free-form) and `status` (M9.1 enum). + * + * Bounds (`limit 1..200`, `offset >= 0`) protect the daemon from accidental + * mass reads and align with the M9.2 store's read-mostly use case. + */ +export const AnalyticsListRequestSchema = z + .object({ + eventName: z.string().optional(), + limit: z.number().int().min(1).max(200), + offset: z.number().int().min(0), + status: z.enum(['pending', 'sent', 'failed']).optional(), + }) + .merge(CliRequestBaseSchema) + +export type AnalyticsListRequest = z.infer + +/** + * Response schema for `analytics:list`. Reuses M9.1's + * `StoredAnalyticsRecordSchema` directly — no separate "wire" variant — + * so a single source of truth covers both the daemon-side store and the + * webui consumer (M11.2's handler enforces this schema on the way out). + * + * `total` is the post-filter row count (NOT total file rows) so a UI can + * render "showing X-Y of total" correctly. + */ +export const AnalyticsListResponseSchema = z.object({ + rows: z.array(StoredAnalyticsRecordSchema), + total: z.number().int().min(0), +}) + +export type AnalyticsListResponse = z.infer diff --git a/src/shared/transport/events/auth-events.ts b/src/shared/transport/events/auth-events.ts index 60f6f5e76..020373a20 100644 --- a/src/shared/transport/events/auth-events.ts +++ b/src/shared/transport/events/auth-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {AuthTokenDTO, BrvConfigDTO, UserDTO} from '../types/dto.js' export const AuthEvents = { @@ -24,6 +26,7 @@ export interface AuthGetStateResponse { } export interface AuthStartLoginRequest { + cli_metadata?: CliMetadata /** * When true, the daemon returns the auth URL without launching the system browser. * Used by clients (e.g. web UI) that prefer to open the URL themselves. @@ -43,6 +46,7 @@ export interface AuthLoginCompletedEvent { export interface AuthLoginWithApiKeyRequest { apiKey: string + cli_metadata?: CliMetadata } export interface AuthLoginWithApiKeyResponse { @@ -51,6 +55,15 @@ export interface AuthLoginWithApiKeyResponse { userEmail?: string } +/** + * M13.2 Group C — `auth:logout` is a no-payload oclif call today. Define the + * Request interface here so M13.3 can attach `cli_metadata`. Handler-side type- + * parameter update is out of scope (deferred emit ticket). + */ +export interface AuthLogoutRequest { + cli_metadata?: CliMetadata +} + export interface AuthLogoutResponse { error?: string success: boolean diff --git a/src/shared/transport/events/connector-events.ts b/src/shared/transport/events/connector-events.ts index a601f1e8c..bfff6d1c1 100644 --- a/src/shared/transport/events/connector-events.ts +++ b/src/shared/transport/events/connector-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {Agent} from '../../types/agent.js' import type {ConnectorType} from '../../types/connector-type.js' import type {AgentDTO, ConnectorDTO} from '../types/dto.js' @@ -9,16 +11,32 @@ export const ConnectorEvents = { LIST: 'connectors:list', } as const +/** + * M13.2 Group C — `connectors:getAgents` is a no-payload oclif call. Define the + * Request interface for M13.3's payload attachment. + */ +export interface ConnectorGetAgentsRequest { + cli_metadata?: CliMetadata +} + export interface ConnectorGetAgentsResponse { agents: AgentDTO[] } +/** + * M13.2 Group C — `connectors:list` is a no-payload oclif call. + */ +export interface ConnectorListRequest { + cli_metadata?: CliMetadata +} + export interface ConnectorListResponse { connectors: ConnectorDTO[] } export interface ConnectorGetAgentConfigPathsRequest { agentId: Agent + cli_metadata?: CliMetadata } export interface ConnectorGetAgentConfigPathsResponse { @@ -27,6 +45,7 @@ export interface ConnectorGetAgentConfigPathsResponse { export interface ConnectorInstallRequest { agentId: Agent + cli_metadata?: CliMetadata connectorType: ConnectorType } diff --git a/src/shared/transport/events/context-tree-events.ts b/src/shared/transport/events/context-tree-events.ts index 964c36d09..93ae0b4c2 100644 --- a/src/shared/transport/events/context-tree-events.ts +++ b/src/shared/transport/events/context-tree-events.ts @@ -1,3 +1,5 @@ +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + /** Transport events for context tree operations (webui ↔ daemon). */ export const ContextTreeEvents = { GET_FILE: 'contextTree:getFile', @@ -12,6 +14,7 @@ export const ContextTreeEvents = { export interface ContextTreeGetNodesRequest { /** Branch to read from. Defaults to current checked-out branch if omitted. */ branch?: string + cli_metadata?: CliMetadata /** Explicit project path. When omitted, uses the client's registered project. */ projectPath?: string } @@ -39,6 +42,7 @@ export interface ContextTreeGetNodesResponse { export interface ContextTreeGetFileRequest { branch?: string + cli_metadata?: CliMetadata /** Relative path within the context tree (e.g. `"architecture/auth.md"`). */ path: string /** Explicit project path. When omitted, uses the client's registered project. */ @@ -65,6 +69,7 @@ export interface ContextTreeGetFileResponse { export interface ContextTreeUpdateFileRequest { branch?: string + cli_metadata?: CliMetadata /** New file content to write. */ content: string /** Relative path within the context tree. */ @@ -80,6 +85,7 @@ export interface ContextTreeUpdateFileResponse { // --- GET_FILE_METADATA --- export interface ContextTreeGetFileMetadataRequest { + cli_metadata?: CliMetadata /** * File or folder paths to fetch metadata for. Folder paths resolve to the * latest commit that modified any descendant of the folder. @@ -102,6 +108,7 @@ export interface ContextTreeGetFileMetadataResponse { // --- GET_HISTORY --- export interface ContextTreeGetHistoryRequest { + cli_metadata?: CliMetadata /** SHA of the last commit from the previous page (for cursor-based pagination). */ cursor?: string /** Max commits per page. Defaults to 10. */ diff --git a/src/shared/transport/events/global-config-events.ts b/src/shared/transport/events/global-config-events.ts new file mode 100644 index 000000000..946b916bd --- /dev/null +++ b/src/shared/transport/events/global-config-events.ts @@ -0,0 +1,31 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + +export const GlobalConfigEvents = { + GET: 'globalConfig:get', + SET_ANALYTICS: 'globalConfig:setAnalytics', +} as const + +/** + * M13.2 Group C — `globalConfig:get` is a no-payload request. Define the + * Request interface so M13.3 can attach `cli_metadata`. + */ +export interface GlobalConfigGetRequest { + cli_metadata?: CliMetadata +} + +export interface GlobalConfigGetResponse { + readonly analytics: boolean + readonly deviceId: string + readonly version: string +} + +export interface GlobalConfigSetAnalyticsRequest { + readonly analytics: boolean + cli_metadata?: CliMetadata +} + +export interface GlobalConfigSetAnalyticsResponse { + readonly current: boolean + readonly previous: boolean +} diff --git a/src/shared/transport/events/hub-events.ts b/src/shared/transport/events/hub-events.ts index 8f91cf50c..1df78112c 100644 --- a/src/shared/transport/events/hub-events.ts +++ b/src/shared/transport/events/hub-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {AuthScheme} from '../types/auth-scheme.js' import type {HubEntryDTO} from '../types/dto.js' @@ -17,6 +19,14 @@ export interface HubProgressEvent { step: string } +/** + * M13.2 Group C — `hub:list` is a no-payload oclif call. Define the Request + * interface so M13.3 can attach `cli_metadata`. + */ +export interface HubListRequest { + cli_metadata?: CliMetadata +} + export interface HubListResponse { entries: HubEntryDTO[] version: string @@ -24,6 +34,7 @@ export interface HubListResponse { export interface HubInstallRequest { agent?: string + cli_metadata?: CliMetadata entryId: string registry?: string scope?: 'global' | 'project' @@ -40,6 +51,7 @@ export interface HubInstallResponse { export interface HubRegistryAddRequest { authScheme?: AuthScheme + cli_metadata?: CliMetadata headerName?: string name: string token?: string @@ -52,6 +64,7 @@ export interface HubRegistryAddResponse { } export interface HubRegistryRemoveRequest { + cli_metadata?: CliMetadata name: string } @@ -70,6 +83,13 @@ export interface HubRegistryDTO { url: string } +/** + * M13.2 Group C — `hub:registry:list` is a no-payload oclif call. + */ +export interface HubRegistryListRequest { + cli_metadata?: CliMetadata +} + export interface HubRegistryListResponse { registries: HubRegistryDTO[] } diff --git a/src/shared/transport/events/index.ts b/src/shared/transport/events/index.ts index 8abba92ae..8ac9bebbb 100644 --- a/src/shared/transport/events/index.ts +++ b/src/shared/transport/events/index.ts @@ -3,12 +3,14 @@ export * from '../types/dto.js' // Event constants and types export * from './agent-events.js' +export * from './analytics-events.js' export * from './auth-events.js' export * from './billing-events.js' export * from './client-events.js' export * from './config-events.js' export * from './connector-events.js' export * from './context-tree-events.js' +export * from './global-config-events.js' export * from './hub-events.js' export * from './init-events.js' export * from './llm-events.js' @@ -33,12 +35,14 @@ export * from './worktree-events.js' // Utility exports import {AgentEvents} from './agent-events.js' +import {AnalyticsEvents} from './analytics-events.js' import {AuthEvents} from './auth-events.js' import {BillingEvents} from './billing-events.js' import {ClientEvents} from './client-events.js' import {ConfigEvents} from './config-events.js' import {ConnectorEvents} from './connector-events.js' import {ContextTreeEvents} from './context-tree-events.js' +import {GlobalConfigEvents} from './global-config-events.js' import {HubEvents} from './hub-events.js' import {InitEvents} from './init-events.js' import {LlmEvents} from './llm-events.js' @@ -67,12 +71,14 @@ import {WorktreeEvents} from './worktree-events.js' */ export const AllEventGroups = [ AgentEvents, + AnalyticsEvents, AuthEvents, BillingEvents, ClientEvents, ConfigEvents, ConnectorEvents, ContextTreeEvents, + GlobalConfigEvents, HubEvents, InitEvents, LlmEvents, diff --git a/src/shared/transport/events/init-events.ts b/src/shared/transport/events/init-events.ts index 50d6657de..c9de56d38 100644 --- a/src/shared/transport/events/init-events.ts +++ b/src/shared/transport/events/init-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {Agent} from '../../types/agent.js' import type {ConnectorType} from '../../types/connector-type.js' import type {AgentDTO, BrvConfigDTO, SpaceDTO, TeamDTO} from '../types/dto.js' @@ -17,6 +19,7 @@ export interface InitGetTeamsResponse { } export interface InitGetSpacesRequest { + cli_metadata?: CliMetadata teamId: string } @@ -30,6 +33,7 @@ export interface InitGetAgentsResponse { export interface InitExecuteRequest { agentId: Agent + cli_metadata?: CliMetadata connectorType: ConnectorType force?: boolean spaceId: string @@ -41,6 +45,7 @@ export interface InitExecuteResponse { } export interface InitLocalRequest { + cli_metadata?: CliMetadata force?: boolean } diff --git a/src/shared/transport/events/locations-events.ts b/src/shared/transport/events/locations-events.ts index 8ab166c0d..df3fe21dc 100644 --- a/src/shared/transport/events/locations-events.ts +++ b/src/shared/transport/events/locations-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {ProjectLocationDTO} from '../types/dto.js' export const LocationsEvents = { @@ -5,11 +7,20 @@ export const LocationsEvents = { REVEAL: 'locations:reveal', } as const +/** + * M13.2 Group C — `locations:get` is a no-payload oclif call. Define the Request + * interface so M13.3 can attach `cli_metadata`. + */ +export interface LocationsGetRequest { + cli_metadata?: CliMetadata +} + export interface LocationsGetResponse { locations: ProjectLocationDTO[] } export interface LocationsRevealRequest { + cli_metadata?: CliMetadata projectPath: string } diff --git a/src/shared/transport/events/model-events.ts b/src/shared/transport/events/model-events.ts index 22427b08e..a43f9659d 100644 --- a/src/shared/transport/events/model-events.ts +++ b/src/shared/transport/events/model-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {ModelDTO} from '../types/dto.js' export const ModelEvents = { @@ -7,6 +9,7 @@ export const ModelEvents = { } as const export interface ModelListRequest { + cli_metadata?: CliMetadata providerId: string } @@ -19,6 +22,7 @@ export interface ModelListResponse { } export interface ModelListByProvidersRequest { + cli_metadata?: CliMetadata providerIds: string[] } @@ -28,6 +32,7 @@ export interface ModelListByProvidersResponse { } export interface ModelSetActiveRequest { + cli_metadata?: CliMetadata contextLength?: number modelId: string providerId: string diff --git a/src/shared/transport/events/onboarding-events.ts b/src/shared/transport/events/onboarding-events.ts index aa0e8fb5d..a2613a2c1 100644 --- a/src/shared/transport/events/onboarding-events.ts +++ b/src/shared/transport/events/onboarding-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const OnboardingEvents = { AUTO_SETUP: 'onboarding:autoSetup', COMPLETE: 'onboarding:complete', @@ -14,6 +17,7 @@ export interface OnboardingAutoSetupResponse { } export interface OnboardingCompleteRequest { + cli_metadata?: CliMetadata skipped?: boolean } diff --git a/src/shared/transport/events/provider-events.ts b/src/shared/transport/events/provider-events.ts index 384cae038..c7f6d958f 100644 --- a/src/shared/transport/events/provider-events.ts +++ b/src/shared/transport/events/provider-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {ProviderDTO} from '../types/dto.js' export const ProviderEvents = { @@ -14,6 +16,14 @@ export const ProviderEvents = { VALIDATE_API_KEY: 'provider:validateApiKey', } as const +/** + * M13.2 Group C — `provider:list` is a no-payload oclif call. Define the Request + * interface so M13.3 can attach `cli_metadata`. + */ +export interface ProviderListRequest { + cli_metadata?: CliMetadata +} + export interface ProviderListResponse { providers: ProviderDTO[] } @@ -21,6 +31,7 @@ export interface ProviderListResponse { export interface ProviderConnectRequest { apiKey?: string baseUrl?: string + cli_metadata?: CliMetadata providerId: string } @@ -30,6 +41,7 @@ export interface ProviderConnectResponse { } export interface ProviderDisconnectRequest { + cli_metadata?: CliMetadata providerId: string } @@ -39,6 +51,7 @@ export interface ProviderDisconnectResponse { export interface ProviderValidateApiKeyRequest { apiKey: string + cli_metadata?: CliMetadata providerId: string } @@ -47,6 +60,13 @@ export interface ProviderValidateApiKeyResponse { isValid: boolean } +/** + * M13.2 Group C — `provider:getActive` is a no-payload oclif call. + */ +export interface ProviderGetActiveRequest { + cli_metadata?: CliMetadata +} + export interface ProviderGetActiveResponse { activeModel?: string activeProviderId: string @@ -55,6 +75,7 @@ export interface ProviderGetActiveResponse { } export interface ProviderSetActiveRequest { + cli_metadata?: CliMetadata providerId: string } @@ -66,6 +87,7 @@ export interface ProviderSetActiveResponse { // ==================== OAuth Events ==================== export interface ProviderCancelOAuthRequest { + cli_metadata?: CliMetadata providerId: string } @@ -74,6 +96,7 @@ export interface ProviderCancelOAuthResponse { } export interface ProviderStartOAuthRequest { + cli_metadata?: CliMetadata mode?: string providerId: string } @@ -86,6 +109,7 @@ export interface ProviderStartOAuthResponse { } export interface ProviderAwaitOAuthCallbackRequest { + cli_metadata?: CliMetadata providerId: string } @@ -95,6 +119,7 @@ export interface ProviderAwaitOAuthCallbackResponse { } export interface ProviderSubmitOAuthCodeRequest { + cli_metadata?: CliMetadata code: string providerId: string } diff --git a/src/shared/transport/events/pull-events.ts b/src/shared/transport/events/pull-events.ts index 51d6f37aa..afccf7ba2 100644 --- a/src/shared/transport/events/pull-events.ts +++ b/src/shared/transport/events/pull-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const PullEvents = { EXECUTE: 'pull:execute', PREPARE: 'pull:prepare', @@ -6,6 +9,7 @@ export const PullEvents = { export interface PullPrepareRequest { branch: string + cli_metadata?: CliMetadata } export interface PullPrepareResponse { @@ -15,6 +19,7 @@ export interface PullPrepareResponse { export interface PullExecuteRequest { branch: string + cli_metadata?: CliMetadata } export interface PullExecuteResponse { diff --git a/src/shared/transport/events/push-events.ts b/src/shared/transport/events/push-events.ts index 52d5ac279..cbd227877 100644 --- a/src/shared/transport/events/push-events.ts +++ b/src/shared/transport/events/push-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const PushEvents = { EXECUTE: 'push:execute', PREPARE: 'push:prepare', @@ -6,6 +9,7 @@ export const PushEvents = { export interface PushPrepareRequest { branch: string + cli_metadata?: CliMetadata } export interface PushPrepareResponse { @@ -22,6 +26,7 @@ export interface PushPrepareResponse { export interface PushExecuteRequest { branch: string + cli_metadata?: CliMetadata } export interface PushExecuteResponse { diff --git a/src/shared/transport/events/review-events.ts b/src/shared/transport/events/review-events.ts index 586519300..388efa8bd 100644 --- a/src/shared/transport/events/review-events.ts +++ b/src/shared/transport/events/review-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const ReviewEvents = { DECIDE_TASK: 'review:decideTask', GET_DISABLED: 'review:getDisabled', @@ -12,6 +15,7 @@ export interface ReviewGetDisabledResponse { } export interface ReviewSetDisabledRequest { + cli_metadata?: CliMetadata reviewDisabled: boolean } @@ -27,6 +31,7 @@ export interface ReviewNotifyEvent { } export interface ReviewDecideTaskRequest { + cli_metadata?: CliMetadata decision: 'approved' | 'rejected' /** When provided, only operations targeting these context-tree-relative paths are affected. */ filePaths?: string[] diff --git a/src/shared/transport/events/settings-events.ts b/src/shared/transport/events/settings-events.ts index 4bcea3d9e..7b11b2f06 100644 --- a/src/shared/transport/events/settings-events.ts +++ b/src/shared/transport/events/settings-events.ts @@ -14,26 +14,45 @@ export const SettingsEvents = { * M7 T2 added three optional fields (`category`, `unit`, `scope`); T1 of * the Update-check toggle project widened `type`, `current`, `default`, * and `restartRequired` to also cover boolean descriptors, and made - * `min` / `max` optional (only integer descriptors carry them). All - * widenings are additive at the JSON layer, so consumers that read - * existing integer fields continue to parse the wire format. + * `min` / `max` optional (only integer descriptors carry them). M16.1 + * added the `'readonly-info'` variant: a snapshot has no `default` and + * `current` may be a structured object or `undefined` (when no info + * provider is registered). All widenings are additive at the JSON + * layer, so consumers that read existing integer fields continue to + * parse the wire format. */ export interface SettingsItemDTO { - category?: 'concurrency' | 'llm' | 'task-history' | 'updates' - current: boolean | number - default: boolean | number + category?: 'analytics' | 'concurrency' | 'llm' | 'task-history' | 'updates' + current: boolean | number | Readonly> | undefined + default?: boolean | number description: string key: string max?: number min?: number restartRequired: boolean scope?: 'global' | 'project' - type: 'boolean' | 'integer' + type: 'boolean' | 'integer' | 'readonly-info' unit?: 'count' | 'ms' } export interface SettingsErrorDTO { - code: 'invalid_value' | 'invalid_value_type' | 'unknown_key' + /** + * Failure category for a settings:* request. + * + * - `'invalid_value'`: value violates a descriptor constraint (range, + * coupling, fractional integer, etc). + * - `'invalid_value_type'`: value's runtime `typeof` did not match the + * descriptor's declared type. + * - `'misconfigured'`: the daemon's wiring of this key is missing or + * incompatible (e.g. a `storage: 'global-config'` descriptor with no + * facade injected, or a non-boolean global-config descriptor — the + * only facade is boolean-only today). Distinct from `invalid_value` + * so logs and the WebUI can distinguish "user gave bad value" from + * "daemon wiring is wrong". + * - `'read_only'`: caller tried to write or reset a `readonly-info` key. + * - `'unknown_key'`: registry has no descriptor for the requested key. + */ + code: 'invalid_value' | 'invalid_value_type' | 'misconfigured' | 'read_only' | 'unknown_key' /** Expected runtime kind, only set when `code === 'invalid_value_type'`. */ expected?: 'boolean' | 'integer' /** `typeof` of the offending value, only set when `code === 'invalid_value_type'`. */ diff --git a/src/shared/transport/events/source-events.ts b/src/shared/transport/events/source-events.ts index bd0848d5e..d535e73bf 100644 --- a/src/shared/transport/events/source-events.ts +++ b/src/shared/transport/events/source-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const SourceEvents = { ADD: 'source:add', LIST: 'source:list', @@ -6,6 +9,7 @@ export const SourceEvents = { export interface SourceAddRequest { alias?: string + cli_metadata?: CliMetadata targetPath: string } @@ -16,6 +20,7 @@ export interface SourceAddResponse { export interface SourceRemoveRequest { aliasOrPath: string + cli_metadata?: CliMetadata } export interface SourceRemoveResponse { @@ -23,7 +28,15 @@ export interface SourceRemoveResponse { success: boolean } -export type SourceListRequest = void +/** + * M13.2 — `SourceListRequest` upgraded from `void` to an interface with optional + * `cli_metadata` so client-side callers can attach invocation metadata. The + * field stays optional, so existing daemon-internal call sites that pass + * nothing continue to work over the wire. + */ +export interface SourceListRequest { + cli_metadata?: CliMetadata +} export interface SourceListResponse { error?: string diff --git a/src/shared/transport/events/space-events.ts b/src/shared/transport/events/space-events.ts index f2b86418a..c89596ec6 100644 --- a/src/shared/transport/events/space-events.ts +++ b/src/shared/transport/events/space-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {BrvConfigDTO, SpaceDTO} from '../types/dto.js' export const SpaceEvents = { @@ -16,6 +18,7 @@ export interface SpaceListResponse { } export interface SpaceSwitchRequest { + cli_metadata?: CliMetadata spaceId: string } diff --git a/src/shared/transport/events/status-events.ts b/src/shared/transport/events/status-events.ts index cc2289c0e..12734a736 100644 --- a/src/shared/transport/events/status-events.ts +++ b/src/shared/transport/events/status-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {StatusDTO} from '../types/dto.js' export const StatusEvents = { @@ -5,6 +7,7 @@ export const StatusEvents = { } as const export interface StatusGetRequest { + cli_metadata?: CliMetadata cwd?: string projectRootFlag?: string verbose?: boolean diff --git a/src/shared/transport/events/swarm-events.ts b/src/shared/transport/events/swarm-events.ts new file mode 100644 index 000000000..1c24cf681 --- /dev/null +++ b/src/shared/transport/events/swarm-events.ts @@ -0,0 +1,60 @@ +/** + * Events for `brv swarm` — federated memory-provider operations. + * + * Three emit-only events the swarm CLI commands and the LLM `swarm_*` + * tools dispatch to the daemon AFTER doing their client-side work + * (`swarm-coordinator` lives in the agent process, not the daemon). + * The handler validates the payload against the matching per-event + * Zod schema in `src/shared/analytics/events/swarm-*.ts` and forwards + * to `analyticsClient.track()`. + * + * Why a dedicated transport namespace vs `analytics:track`: + * - Typed wire surface — request shapes mirror the analytics + * schemas so the CLI gets compile-time validation. + * - Stable seam — when (if) the swarm coordinator is moved into the + * daemon, this same transport channel will carry the operation + * request itself. The emit event names stay the same; only the + * handler internals change. + */ + +import type {SwarmOnboardedProps} from '../../analytics/events/swarm-onboarded.js' +import type {SwarmQueryCompletedProps} from '../../analytics/events/swarm-query-completed.js' +import type {SwarmStoreCompletedProps} from '../../analytics/events/swarm-store-completed.js' + +export const SwarmEvents = { + TRACK_ONBOARDED: 'swarm:trackOnboarded', + TRACK_QUERY_COMPLETED: 'swarm:trackQueryCompleted', + TRACK_STORE_COMPLETED: 'swarm:trackStoreCompleted', +} as const + +/** + * Wire shape mirrors `SwarmQueryCompletedProps` exactly. Re-exported here + * so CLI callers can import a transport-flavored type even though the + * shape is structurally identical to the analytics props. + */ +export type SwarmTrackQueryCompletedRequest = SwarmQueryCompletedProps + +export type SwarmTrackStoreCompletedRequest = SwarmStoreCompletedProps + +export type SwarmTrackOnboardedRequest = SwarmOnboardedProps + +/** + * Closed enum so a typo or stray ad-hoc reason becomes a compile error + * rather than a silent miss on the consumer side. + */ +export type SwarmTrackReason = 'analytics-throw' | 'analytics-unavailable' | 'schema-rejection' + +/** + * The handler returns a small ack so the CLI can confirm the emit was + * accepted (or learn it was schema-rejected). Analytics-handler.ts pattern. + */ +export interface SwarmTrackResponse { + /** Set when the daemon dropped the emit; populated for schema-rejection or analytics-disabled. */ + reason?: SwarmTrackReason + /** + * True when the daemon accepted the payload and forwarded to the + * analytics client. False when validation failed or the analytics + * client was unavailable. + */ + tracked: boolean +} diff --git a/src/shared/transport/events/task-events.ts b/src/shared/transport/events/task-events.ts index 7877eeeb3..b98831d8c 100644 --- a/src/shared/transport/events/task-events.ts +++ b/src/shared/transport/events/task-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + /** * Persisted-entry schema version. Bumped only on shape-breaking changes to * `TaskHistoryEntry`. The Zod schema in `server/core/domain/entities/` uses @@ -35,6 +38,7 @@ export interface TaskHeartbeatEvent { } export interface TaskCreateRequest { + cli_metadata?: CliMetadata clientCwd?: string content: string files?: string[] @@ -50,6 +54,7 @@ export interface TaskAckResponse { } export interface TaskCancelRequest { + cli_metadata?: CliMetadata taskId: string } @@ -94,6 +99,15 @@ export type ReasoningContentItem = { * stored in `TaskHistoryEntry.toolCalls`. */ export type ToolCallEvent = { + /** + * PR #728 review fix (M17): true when this entry was produced by the + * tool-mode synthetic-emit path (`synthetic-tool-result-emit.ts`) + * rather than a real LLM-driven tool call. The accumulator carries the + * flag forward from the inbound event's `metadata._synthetic` marker so + * downstream consumers (history persistence, WebUI task-detail panel) + * can filter or hide them as internal plumbing. + */ + _synthetic?: true args: Record callId?: string error?: string @@ -135,6 +149,7 @@ export interface TaskListItem { * All filter dims are optional; AND-combined when multiple are set. */ export interface TaskListRequest { + cli_metadata?: CliMetadata /** createdAt >= this epoch ms */ createdAfter?: number /** createdAt <= this epoch ms */ @@ -203,6 +218,7 @@ export interface TaskListResponse { } export type TaskClearCompletedRequest = { + cli_metadata?: CliMetadata projectPath?: string } @@ -212,6 +228,7 @@ export type TaskClearCompletedResponse = { } export type TaskDeleteBulkRequest = { + cli_metadata?: CliMetadata taskIds: string[] } @@ -221,6 +238,7 @@ export type TaskDeleteBulkResponse = { } export type TaskDeleteRequest = { + cli_metadata?: CliMetadata taskId: string } @@ -240,6 +258,7 @@ export type TaskDeletedEvent = { } export type TaskGetRequest = { + cli_metadata?: CliMetadata taskId: string } diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index ef33b328e..078ac6985 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const VcErrorCode = { ALREADY_INITIALIZED: 'ERR_VC_ALREADY_INITIALIZED', AUTH_FAILED: 'ERR_VC_AUTH_FAILED', @@ -60,11 +63,29 @@ export const VcEvents = { STATUS: 'vc:status', } as const +/** + * M13.2 Group C — `vc:init` is a no-payload oclif call today (`onRequest` + * registered at `vc-handler.ts:198`). Create the Request interface here so oclif can + * attach `cli_metadata`. Daemon's handler-side type-parameter update is out of M13 + * scope (deferred emit ticket). + */ +export interface IVcInitRequest { + cli_metadata?: CliMetadata +} + export interface IVcInitResponse { gitDir: string reinitialized: boolean } +/** + * M13.2 Group C — `vc:status` is a no-payload oclif call. Create matching Request + * interface so M13.3's oclif sweep can attach `cli_metadata`. + */ +export interface IVcStatusRequest { + cli_metadata?: CliMetadata +} + export interface IVcStatusResponse { ahead?: number behind?: number @@ -81,6 +102,7 @@ export interface IVcStatusResponse { } export interface IVcAddRequest { + cli_metadata?: CliMetadata filePaths?: string[] } @@ -89,6 +111,7 @@ export interface IVcAddResponse { } export interface IVcCommitRequest { + cli_metadata?: CliMetadata message: string } @@ -106,6 +129,7 @@ export function isVcConfigKey(key: string): key is VcConfigKey { } export interface IVcConfigRequest { + cli_metadata?: CliMetadata key: VcConfigKey value?: string } @@ -117,6 +141,7 @@ export interface IVcConfigResponse { export interface IVcPushRequest { branch?: string + cli_metadata?: CliMetadata setUpstream?: boolean } @@ -127,6 +152,7 @@ export interface IVcPushResponse { } export interface IVcFetchRequest { + cli_metadata?: CliMetadata ref?: string remote?: string } @@ -138,6 +164,7 @@ export interface IVcFetchResponse { export interface IVcPullRequest { allowUnrelatedHistories?: boolean branch?: string + cli_metadata?: CliMetadata remote?: string } @@ -149,6 +176,7 @@ export interface IVcPullResponse { export interface IVcLogRequest { all?: boolean + cli_metadata?: CliMetadata limit?: number ref?: string } @@ -187,6 +215,7 @@ export function isVcRemoteSubcommand(value: string): value is VcRemoteSubcommand } export interface IVcRemoteRequest { + cli_metadata?: CliMetadata subcommand: VcRemoteSubcommand url?: string } @@ -197,8 +226,8 @@ export interface IVcRemoteResponse { } export type IVcCloneRequest = - | {spaceId: string; spaceName: string; teamId: string; teamName: string; url?: never} - | {spaceId?: string; spaceName?: string; teamId?: string; teamName?: string; url: string} + | {cli_metadata?: CliMetadata; spaceId: string; spaceName: string; teamId: string; teamName: string; url?: never} + | {cli_metadata?: CliMetadata; spaceId?: string; spaceName?: string; teamId?: string; teamName?: string; url: string} export interface IVcCloneResponse { gitDir: string @@ -214,10 +243,10 @@ export interface IVcCloneProgressEvent { export type VcBranchAction = 'create' | 'delete' | 'list' | 'set-upstream' export type IVcBranchRequest = - | {action: 'create'; name: string; startPoint?: string} - | {action: 'delete'; name: string} - | {action: 'list'; all?: boolean} - | {action: 'set-upstream'; upstream: string} + | {action: 'create'; cli_metadata?: CliMetadata; name: string; startPoint?: string} + | {action: 'delete'; cli_metadata?: CliMetadata; name: string} + | {action: 'list'; all?: boolean; cli_metadata?: CliMetadata} + | {action: 'set-upstream'; cli_metadata?: CliMetadata; upstream: string} export interface VcBranch { isCurrent: boolean @@ -233,6 +262,7 @@ export type IVcBranchResponse = export interface IVcCheckoutRequest { branch: string + cli_metadata?: CliMetadata create?: boolean force?: boolean /** Ref to create the new branch from when `create` is true. Ignored otherwise. */ @@ -251,6 +281,7 @@ export interface IVcMergeRequest { action: VcMergeAction allowUnrelatedHistories?: boolean branch?: string + cli_metadata?: CliMetadata message?: string } @@ -265,6 +296,7 @@ export interface IVcMergeResponse { export type VcResetMode = 'hard' | 'mixed' | 'soft' export interface IVcResetRequest { + cli_metadata?: CliMetadata filePaths?: string[] mode?: VcResetMode ref?: string @@ -284,6 +316,7 @@ export interface IVcResetResponse { export type VcDiffSide = 'staged' | 'unstaged' export interface IVcDiffRequest { + cli_metadata?: CliMetadata path: string side: VcDiffSide } @@ -306,7 +339,9 @@ export interface IVcDiffResponse { * * The union form guarantees callers can't accidentally mix the two shapes (type error). */ -export type IVcDiffsRequest = {mode: VcDiffMode} | {paths: string[]; side: VcDiffSide} +export type IVcDiffsRequest = + | {cli_metadata?: CliMetadata; mode: VcDiffMode} + | {cli_metadata?: CliMetadata; paths: string[]; side: VcDiffSide} /** * Diff modes for `brv vc diff` / `/vc diff`. Mirrors the four diff modes from `git diff`: @@ -357,6 +392,7 @@ export interface IVcDiffsResponse { * Staged changes in the index are preserved. */ export interface IVcDiscardRequest { + cli_metadata?: CliMetadata filePaths: string[] } diff --git a/src/shared/transport/events/worktree-events.ts b/src/shared/transport/events/worktree-events.ts index 325dd7889..e55fbe8b0 100644 --- a/src/shared/transport/events/worktree-events.ts +++ b/src/shared/transport/events/worktree-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const WorktreeEvents = { ADD: 'worktree:add', LIST: 'worktree:list', @@ -5,6 +8,7 @@ export const WorktreeEvents = { } as const export interface WorktreeAddRequest { + cli_metadata?: CliMetadata force?: boolean worktreePath: string } @@ -16,6 +20,7 @@ export interface WorktreeAddResponse { } export interface WorktreeRemoveRequest { + cli_metadata?: CliMetadata worktreePath: string } @@ -24,7 +29,14 @@ export interface WorktreeRemoveResponse { success: boolean } -export type WorktreeListRequest = void +/** + * M13.2 — `WorktreeListRequest` upgraded from `void` to an interface with + * optional `cli_metadata` so client-side callers can attach invocation + * metadata. Field stays optional, so wire-level back-compat is preserved. + */ +export interface WorktreeListRequest { + cli_metadata?: CliMetadata +} export interface WorktreeListResponse { projectRoot: string diff --git a/src/shared/types/settings-row.ts b/src/shared/types/settings-row.ts index 23cf81983..422e81f53 100644 --- a/src/shared/types/settings-row.ts +++ b/src/shared/types/settings-row.ts @@ -1,4 +1,4 @@ -export type SettingsRowCategory = 'concurrency' | 'llm' | 'other' | 'task-history' | 'updates' +export type SettingsRowCategory = 'analytics' | 'concurrency' | 'llm' | 'other' | 'task-history' | 'updates' export type SettingsRowUnit = 'count' | 'ms' /** @@ -9,14 +9,18 @@ export type SettingsRowUnit = 'count' | 'ms' * Restart requirement is propagated from the descriptor verbatim (no * literal `true` constraint) so the dirty-banner filter on the page can * gate the restart warning per row. + * + * Readonly-info rows carry no `default` / `displayDefault`. The + * renderer must omit the `(default ...)` cell for them and skip + * edit / toggle / reset keybinds. */ export interface SettingsRow { readonly category: SettingsRowCategory - readonly current: boolean | number - readonly default: boolean | number + readonly current: boolean | number | Readonly> | undefined + readonly default?: boolean | number readonly description: string readonly displayCurrent: string - readonly displayDefault: string + readonly displayDefault?: string readonly displayRange: string readonly key: string readonly label: string @@ -24,7 +28,7 @@ export interface SettingsRow { readonly min?: number readonly modified: boolean readonly restartRequired: boolean - readonly type: 'boolean' | 'integer' + readonly type: 'boolean' | 'integer' | 'readonly-info' readonly unit?: SettingsRowUnit } @@ -37,5 +41,6 @@ export const CATEGORY_ORDER: readonly SettingsRowCategory[] = [ 'llm', 'task-history', 'updates', + 'analytics', 'other', ] diff --git a/src/shared/utils/format-analytics-status.ts b/src/shared/utils/format-analytics-status.ts new file mode 100644 index 000000000..d051361fe --- /dev/null +++ b/src/shared/utils/format-analytics-status.ts @@ -0,0 +1,115 @@ +/* eslint-disable camelcase -- legacy `brv settings get analytics.status --format json` envelope is snake_case. */ +import type {AnalyticsStatusResponse} from '../transport/events/analytics-events.js' + +import {AnalyticsStatusResponseSchema} from '../transport/events/analytics-events.js' +import {registerReadonlyInfoFormatter} from './format-readonly-info.js' + +const MS_PER_MIN = 60_000 +const MS_PER_HOUR = 60 * MS_PER_MIN +const MS_PER_DAY = 24 * MS_PER_HOUR + +const UNAVAILABLE_TEXT = '(unavailable)' + +/** + * Humanise a millisecond delta to a short relative-time label, matching + * the M4.6 ticket example: `(5m ago)`. Cut points: + * - < 1 minute -> "just now" + * - < 1 hour -> "{n}m ago" + * - < 1 day -> "{n}h ago" + * - >= 1 day -> "{n}d ago" + */ +export function formatRelativeAgo(deltaMs: number): string { + if (!Number.isFinite(deltaMs) || deltaMs < MS_PER_MIN) return 'just now' + if (deltaMs < MS_PER_HOUR) return `${Math.floor(deltaMs / MS_PER_MIN)}m ago` + if (deltaMs < MS_PER_DAY) return `${Math.floor(deltaMs / MS_PER_HOUR)}h ago` + return `${Math.floor(deltaMs / MS_PER_DAY)}d ago` +} + +/** + * Humanise a forward-looking delay in milliseconds. Cut points mirror the + * M4.5 backoff schedule (30s, 60s, 2m, 5m). + */ +export function formatDelayMs(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0ms' + if (ms < 1000) return `${ms}ms` + if (ms < MS_PER_MIN) return `${Math.floor(ms / 1000)}s` + if (ms < MS_PER_HOUR) return `${Math.floor(ms / MS_PER_MIN)}m` + return `${Math.floor(ms / MS_PER_HOUR)}h` +} + +/** + * Renders the analytics-status snapshot as the multi-line text block the + * legacy `brv analytics status` (now `brv settings get analytics.status`) printed. The output is consumed by + * both `brv settings get analytics.status` and `brv settings list` + * (per-key readonly-info formatter) — and by the TUI settings page via + * the same shared registry. + * + * Accepts `unknown` because the formatter registry surface is wider than + * any single key's snapshot shape. Falls back to `(unavailable)` when + * the value does not match `AnalyticsStatusResponseSchema`. + */ +export function formatAnalyticsStatusText(value: unknown, now: () => number = Date.now): string { + const parsed = AnalyticsStatusResponseSchema.safeParse(value) + if (!parsed.success) return UNAVAILABLE_TEXT + + const response = parsed.data + if (!response.enabled) return 'Analytics: disabled' + + return [ + 'Analytics: enabled', + `Last successful flush: ${formatLastFlush(response.lastFlushAt, now)}`, + `Queue depth: ${response.queueDepth} events`, + `Dropped events (this session): ${response.droppedCount}`, + `Backoff state: ${response.backoff.state} (${formatBackoffSummary(response.backoff)})`, + `Endpoint: ${response.endpoint}`, + ].join('\n') +} + +/** + * JSON wire shape matching the legacy `brv analytics status --format json` + * envelope (now `brv settings get analytics.status --format json`, snake_case + * fields preserved). Consumed by + * `brv settings get analytics.status --format json` so callers depending + * on the legacy programmatic shape do not break when the legacy command + * is deleted in M16.4. + */ +export function formatAnalyticsStatusJson(value: unknown): Readonly> { + const parsed = AnalyticsStatusResponseSchema.safeParse(value) + if (!parsed.success) return {unavailable: true} + + const response = parsed.data + return { + backoff: { + consecutive_failures: response.backoff.consecutiveFailures, + next_delay_ms: response.backoff.nextDelayMs, + state: response.backoff.state, + }, + dropped_events: response.droppedCount, + enabled: response.enabled, + endpoint: response.endpoint, + last_flush: response.lastFlushAt === undefined ? null : new Date(response.lastFlushAt).toISOString(), + queue_depth: response.queueDepth, + } +} + +function formatLastFlush(lastFlushAt: number | undefined, now: () => number): string { + if (lastFlushAt === undefined) return 'never' + const iso = new Date(lastFlushAt).toISOString() + const ago = formatRelativeAgo(now() - lastFlushAt) + return `${iso} (${ago})` +} + +function formatBackoffSummary(backoff: AnalyticsStatusResponse['backoff']): string { + const failurePart = + backoff.consecutiveFailures === 1 + ? '1 consecutive failure' + : `${backoff.consecutiveFailures} consecutive failures` + return `${failurePart}, next attempt in ${formatDelayMs(backoff.nextDelayMs)}` +} + +// Self-register the analytics.status formatter so any consumer of +// `formatReadonlyInfoValue('analytics.status', ...)` (CLI list/get, +// TUI settings page, future WebUI cleanup) gets the legacy text shape +// without an explicit boot-time registration step. M16.1's +// double-register guard makes accidental re-imports a no-op. +registerReadonlyInfoFormatter('analytics.status', formatAnalyticsStatusText) diff --git a/src/shared/utils/format-readonly-info.ts b/src/shared/utils/format-readonly-info.ts new file mode 100644 index 000000000..4ed27ccaf --- /dev/null +++ b/src/shared/utils/format-readonly-info.ts @@ -0,0 +1,59 @@ +/** + * Per-key text formatter registry for `readonly-info` settings descriptors. + * + * Both the oclif CLI (`brv settings list` / `get`) and the TUI settings + * page read live operational snapshots through this registry. The default + * formatter renders `undefined` as `(unavailable)`, strings as-is, and + * everything else via `JSON.stringify`. Consumers (e.g. t3's + * `analytics.status` module) call `registerReadonlyInfoFormatter` at + * module load time to install a human-friendly view for their key. + * + * The registry lives in `shared/` so neither surface crosses the + * `tui/` <-> `oclif/` boundary; both import the same singleton. + * + * Type asymmetry note: `ReadonlyInfoSnapshot` (in + * `server/infra/transport/handlers/settings-handler.ts`) tightly + * constrains what an info PROVIDER may return. `ReadonlyInfoFormatter` + * deliberately accepts `unknown` so a formatter can stay robust against + * unexpected wire shapes (e.g. legacy clients, hand-edited fixtures). + * The default formatter even handles `string`, which the snapshot type + * does not allow — both layers are correct: the snapshot guards the + * write boundary, the formatter guards the read boundary. + */ + +export type ReadonlyInfoFormatter = (value: unknown) => string + +const FORMATTERS = new Map() + +/** + * Installs a per-key formatter. Idempotent on the same function reference. + * Throws when a DIFFERENT function is registered for an already-registered + * key, to surface accidental overrides that would otherwise silently + * mask the canonical formatter. + */ +export function registerReadonlyInfoFormatter(key: string, fn: ReadonlyInfoFormatter): void { + const existing = FORMATTERS.get(key) + if (existing !== undefined && existing !== fn) { + throw new Error( + `Readonly-info formatter for '${key}' is already registered. Call unregisterReadonlyInfoFormatter first, or reuse the same function reference.`, + ) + } + + FORMATTERS.set(key, fn) +} + +export function unregisterReadonlyInfoFormatter(key: string): void { + FORMATTERS.delete(key) +} + +export function formatReadonlyInfoValue(key: string, value: unknown): string { + const fn = FORMATTERS.get(key) + if (fn) return fn(value) + return defaultFormat(value) +} + +function defaultFormat(value: unknown): string { + if (value === undefined) return '(unavailable)' + if (typeof value === 'string') return value + return JSON.stringify(value) +} diff --git a/src/shared/utils/format-settings.ts b/src/shared/utils/format-settings.ts index 79d415e7e..fa166e697 100644 --- a/src/shared/utils/format-settings.ts +++ b/src/shared/utils/format-settings.ts @@ -2,11 +2,22 @@ import type {SettingsItemDTO} from '../transport/events/settings-events.js' import type {RowParseResult, SettingsRow, SettingsRowCategory, SettingsRowUnit} from '../types/settings-row.js' import {CATEGORY_ORDER} from '../types/settings-row.js' +// Side-effect import: registers the analytics.status readonly-info text +// formatter so `formatReadonlyInfoValue('analytics.status', ...)` returns +// the legacy text shape regardless of which surface (CLI / TUI / WebUI) +// triggers the first read. +import './format-analytics-status.js' import {formatCount, formatDuration, parseDuration} from './format-duration.js' +import {formatReadonlyInfoValue} from './format-readonly-info.js' export function buildSettingsRows(items: readonly SettingsItemDTO[]): SettingsRow[] { const rows: SettingsRow[] = [] for (const item of items) { + if (item.type === 'readonly-info') { + rows.push(toReadonlyInfoRow(item)) + continue + } + if (item.type === 'boolean' && typeof item.current === 'boolean' && typeof item.default === 'boolean') { rows.push(toBooleanRow(item, item.current, item.default)) continue @@ -124,12 +135,38 @@ function toBooleanRow(item: SettingsItemDTO, current: boolean, defaultValue: boo } } +function toReadonlyInfoRow(item: SettingsItemDTO): SettingsRow { + // Row views (CLI list, TUI page) are single-line per row. If the per-key + // formatter returns a multi-line snapshot (e.g. `analytics.status`), + // surface only the headline so the table stays aligned; users see the + // full block via `brv settings get `. + const fullText = formatReadonlyInfoValue(item.key, item.current) + return { + category: toRowCategory(item.category), + current: item.current, + description: item.description, + displayCurrent: fullText.split('\n')[0], + displayRange: '', + key: item.key, + label: item.key, + modified: false, + restartRequired: item.restartRequired, + type: 'readonly-info', + } +} + function renderBoolean(value: boolean): string { return value ? '[ on ]' : '[ off ]' } function toRowCategory(category: SettingsItemDTO['category']): SettingsRowCategory { - if (category === 'concurrency' || category === 'llm' || category === 'task-history' || category === 'updates') { + if ( + category === 'analytics' || + category === 'concurrency' || + category === 'llm' || + category === 'task-history' || + category === 'updates' + ) { return category } diff --git a/src/shared/utils/load-analytics-disclosure.ts b/src/shared/utils/load-analytics-disclosure.ts new file mode 100644 index 000000000..8928f9824 --- /dev/null +++ b/src/shared/utils/load-analytics-disclosure.ts @@ -0,0 +1,16 @@ +import {readFile} from 'node:fs/promises' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +/** + * Canonical disclosure markdown lives in `src/shared/assets/` so both + * oclif (CLI consent prompt) and TUI (settings-page inline confirm) + * can read it without crossing the import boundary. The build script + * copies `src/shared/assets/` to `dist/shared/assets/`. + */ +const here = dirname(fileURLToPath(import.meta.url)) +const DISCLOSURE_PATH = resolve(here, '../assets/analytics-disclosure.md') + +export async function loadAnalyticsDisclosureText(): Promise { + return readFile(DISCLOSURE_PATH, 'utf8') +} diff --git a/src/tui/features/settings/components/settings-page.tsx b/src/tui/features/settings/components/settings-page.tsx index 3a7ca99f9..95041b21d 100644 --- a/src/tui/features/settings/components/settings-page.tsx +++ b/src/tui/features/settings/components/settings-page.tsx @@ -1,16 +1,18 @@ -import {Box, Text, useInput} from 'ink' +import {Box, Text, useInput, useStdout} from 'ink' import Spinner from 'ink-spinner' -import React, {useCallback, useMemo, useState} from 'react' +import React, {useCallback, useEffect, useMemo, useState} from 'react' import type {SettingsRow} from '../../../../shared/types/settings-row.js' import type {CustomDialogCallbacks} from '../../../types/commands.js' +import {ANALYTICS_ENABLED_KEY} from '../../../../shared/constants/settings-keys.js' import {buildSettingsRows, parseRowInput} from '../../../../shared/utils/format-settings.js' +import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js' import {useTheme} from '../../../hooks/index.js' import {useGetSettings, useResetSetting, useSetSetting} from '../api/settings-api.js' import {bottomHintFor, groupRowsByCategory, preFillBufferFor} from '../utils/format-settings.js' -type Mode = 'browse' | 'edit' | 'saving' +type Mode = 'browse' | 'confirm-disclosure' | 'edit' | 'saving' export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): React.ReactNode { const {data, error, isLoading} = useGetSettings() @@ -25,12 +27,36 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea const [editBuffer, setEditBuffer] = useState('') const [rowError, setRowError] = useState() const [dirtyKeys, setDirtyKeys] = useState>(new Set()) + const [disclosureText, setDisclosureText] = useState() + const [disclosureScroll, setDisclosureScroll] = useState(0) + const [pendingDisclosureRow, setPendingDisclosureRow] = useState() + + // Tracks terminal height so the disclosure overlay can slice the + // markdown to fit a short window. Falls back to a sensible default + // when stdout has no row count (e.g. piped output, tests). + const {stdout} = useStdout() + const [terminalRows, setTerminalRows] = useState(stdout?.rows ?? 24) + useEffect(() => { + if (!stdout) return + const handler = (): void => setTerminalRows(stdout.rows ?? 24) + stdout.on('resize', handler) + return () => { + stdout.off('resize', handler) + } + }, [stdout]) const rows = useMemo(() => (data ? buildSettingsRows(data.items) : []), [data]) const groups = useMemo(() => groupRowsByCategory(rows), [rows]) const focusedRow = rows[cursor] + // `hintMode` only feeds the bottom hint on the row-list render. The + // `confirm-disclosure` mode short-circuits that render entirely (its + // own hint is inlined below), so it never reaches `bottomHintFor`. const hintMode: 'browse' | 'edit' | 'edit-error' | 'saving' = - mode === 'edit' && rowError !== undefined ? 'edit-error' : mode + mode === 'confirm-disclosure' + ? 'browse' + : mode === 'edit' && rowError !== undefined + ? 'edit-error' + : mode // Restart warning fires only when at least one dirty key actually // requires a daemon restart. Boolean toggles (e.g. update.checkForUpdates, @@ -99,12 +125,11 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea [resetMutation], ) - const toggleBoolean = useCallback( - async (row: SettingsRow) => { - if (row.type !== 'boolean' || typeof row.current !== 'boolean') return + const performToggle = useCallback( + async (row: SettingsRow, nextValue: boolean) => { setMode('saving') setRowError(undefined) - const response = await setMutation.mutateAsync({key: row.key, value: !row.current}) + const response = await setMutation.mutateAsync({key: row.key, value: nextValue}) if (response.ok) { setDirtyKeys((previous) => { const next = new Set(previous) @@ -121,6 +146,35 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea [setMutation], ) + const toggleBoolean = useCallback( + async (row: SettingsRow) => { + if (row.type !== 'boolean' || typeof row.current !== 'boolean') return + + // analytics.share false -> true requires the disclosure consent + // prompt. Load the markdown and switch into the confirm-disclosure + // mode; the user must press Enter to accept (which fires the actual + // SET) or Esc to cancel. + if (row.key === ANALYTICS_ENABLED_KEY && row.current === false) { + setRowError(undefined) + setPendingDisclosureRow(row) + setDisclosureScroll(0) + try { + const text = await loadAnalyticsDisclosureText() + setDisclosureText(text) + setMode('confirm-disclosure') + } catch (error) { + setRowError(error instanceof Error ? error.message : String(error)) + setPendingDisclosureRow(undefined) + } + + return + } + + await performToggle(row, !row.current) + }, + [performToggle], + ) + useInput( (input, key) => { if (key.escape) { @@ -144,6 +198,12 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea if (key.return || input === ' ') { const row = rows[cursor] + if (row.type === 'readonly-info') { + // Read-only rows refuse every mutation keybind: no toggle, no + // edit, no reset. Selection still works (Up/Down navigate). + return + } + if (row.type === 'boolean') { toggleBoolean(row).catch(() => {}) } else { @@ -154,7 +214,9 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea } if (input?.toLowerCase() === 'r') { - resetRow(rows[cursor]).catch(() => {}) + const row = rows[cursor] + if (row.type === 'readonly-info') return + resetRow(row).catch(() => {}) } }, {isActive: mode === 'browse'}, @@ -195,6 +257,74 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea {isActive: mode === 'saving'}, ) + // Disclosure confirm: Enter accepts and flips the flag, Esc cancels + // without flipping. Up/Down + PgUp/PgDn scroll the markdown when it + // doesn't fit in the terminal height. + const disclosureLines = useMemo( + () => (disclosureText === undefined ? [] : disclosureText.split('\n')), + [disclosureText], + ) + // Reserved chrome (defensive — includes the REPL's own bottom status + // bar that wraps this slash-command, and a margin for stray line + // wraps): header (1) + header-gap (1) + top-overflow (1) + + // bottom-overflow (1) + footer-gap (1) + footer (1) + REPL chrome (1) + // + wrap safety (5) = 12 rows. + const disclosureViewportRows = Math.max(2, terminalRows - 12) + const maxScroll = Math.max(0, disclosureLines.length - disclosureViewportRows) + const clampedScroll = Math.min(disclosureScroll, maxScroll) + const visibleLines = disclosureLines.slice(clampedScroll, clampedScroll + disclosureViewportRows) + const hasMoreAbove = clampedScroll > 0 + const hasMoreBelow = clampedScroll < maxScroll + + useInput( + (input, key) => { + if (key.escape) { + setMode('browse') + setPendingDisclosureRow(undefined) + return + } + + if (key.return) { + const row = pendingDisclosureRow + setPendingDisclosureRow(undefined) + if (row !== undefined) { + // `performToggle` itself calls `setRowError` on a non-`ok` response, + // but a thrown rejection (e.g. transport disconnect) would otherwise + // be swallowed and leave the UI in `browse` mode with no feedback + // about the failed enable. Surface the error message and restore + // the browse mode explicitly. + performToggle(row, true).catch((error_: unknown) => { + setRowError(error_ instanceof Error ? error_.message : String(error_)) + setMode('browse') + }) + } + + return + } + + if (key.upArrow) { + setDisclosureScroll((s) => Math.max(0, s - 1)) + return + } + + if (key.downArrow) { + setDisclosureScroll((s) => Math.min(maxScroll, s + 1)) + return + } + + // Page-scroll via PgUp/PgDn or space/b (mirroring `less`). + if (key.pageUp || input === 'b') { + setDisclosureScroll((s) => Math.max(0, s - disclosureViewportRows)) + return + } + + if (key.pageDown || input === ' ') { + setDisclosureScroll((s) => Math.min(maxScroll, s + disclosureViewportRows)) + } + }, + {isActive: mode === 'confirm-disclosure'}, + ) + React.useEffect(() => { if (error) { onComplete(`Failed to load settings: ${error.message}`) @@ -209,9 +339,47 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea ) } + // Disclosure overlay. Renders the markdown sliced to fit the terminal + // height so a short window still shows the Enter/Esc footer. Up/Down + // and PgUp/PgDn scroll through the body. The body Box uses an explicit + // height + flexShrink=1 so wrapped markdown lines cannot push the + // sticky footer off-screen. + if (mode === 'confirm-disclosure') { + return ( + + + ANALYTICS DISCLOSURE + + {disclosureText === undefined ? ( + + Loading disclosure... + + ) : ( + + {hasMoreAbove ? '↑ more above' : ' '} + {visibleLines.map((line, idx) => ( + // Each body line gets its own Text with truncate-end so a long + // markdown line in a narrow terminal stays one visual row and + // cannot push the sticky footer off-screen. + + {line.length === 0 ? ' ' : line} + + ))} + {hasMoreBelow ? '↓ more below' : ' '} + + )} + + + Enter: enable analytics | Esc: cancel | ↑↓ scroll + + + + ) + } + const keyWidth = Math.max(40, ...rows.map((r) => r.label.length)) const currentWidth = Math.max(7, ...rows.map((r) => r.displayCurrent.length)) - const defaultWidth = Math.max(8, ...rows.map((r) => r.displayDefault.length)) + const defaultWidth = Math.max(8, ...rows.map((r) => (r.displayDefault ?? '').length)) const rangeWidth = Math.max(8, ...rows.map((r) => r.displayRange.length)) return ( @@ -242,11 +410,14 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea isSavingThis, width: currentWidth, }) + const trailingCell = row.type === 'readonly-info' + ? pad('(read-only)', defaultWidth + 10) + : `${pad(`(default ${row.displayDefault ?? ''})`, defaultWidth + 10)} ${pad(row.displayRange, rangeWidth)}` return ( {marker} - {pad(row.label, keyWidth)} {currentDisplay} {pad(`(default ${row.displayDefault})`, defaultWidth + 10)} {pad(row.displayRange, rangeWidth)} + {pad(row.label, keyWidth)} {currentDisplay} {trailingCell} {isSelected && rowError !== undefined && ( @@ -260,7 +431,7 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea ))} - {bottomHintFor(hintMode, focusedRow?.key)} + {bottomHintFor(hintMode, focusedRow?.key, focusedRow?.type)} ) diff --git a/src/tui/features/settings/utils/format-settings.ts b/src/tui/features/settings/utils/format-settings.ts index 4c171fb83..29224d75e 100644 --- a/src/tui/features/settings/utils/format-settings.ts +++ b/src/tui/features/settings/utils/format-settings.ts @@ -2,6 +2,7 @@ import {CATEGORY_ORDER, type SettingsRow, type SettingsRowCategory} from '../../ import {formatDuration} from '../../../../shared/utils/format-duration.js' const CATEGORY_HEADERS: Readonly> = { + analytics: 'ANALYTICS', concurrency: 'CONCURRENCY', llm: 'LLM', other: 'OTHER', @@ -32,9 +33,17 @@ export function groupRowsByCategory(rows: readonly SettingsRow[]): ReadonlyArray return result } -export function bottomHintFor(mode: 'browse' | 'edit' | 'edit-error' | 'saving', focusedKey?: string): string { +export function bottomHintFor( + mode: 'browse' | 'edit' | 'edit-error' | 'saving', + focusedKey?: string, + focusedRowType?: 'boolean' | 'integer' | 'readonly-info', +): string { switch (mode) { case 'browse': { + if (focusedRowType === 'readonly-info') { + return 'Up/Down move | Esc exit | (read-only)' + } + return 'Up/Down move | Enter edit | R reset | Esc exit' } diff --git a/src/webui/features/analytics/api/get-global-config.ts b/src/webui/features/analytics/api/get-global-config.ts new file mode 100644 index 000000000..0966d50c8 --- /dev/null +++ b/src/webui/features/analytics/api/get-global-config.ts @@ -0,0 +1,32 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' + +import type {QueryConfig} from '../../../lib/react-query' + +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, +} from '../../../../shared/transport/events/global-config-events.js' +import {useTransportStore} from '../../../stores/transport-store' + +export const getGlobalConfig = (): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request(GlobalConfigEvents.GET) +} + +export const getGlobalConfigQueryOptions = () => + queryOptions({ + queryFn: getGlobalConfig, + queryKey: ['globalConfig'], + refetchOnWindowFocus: true, + }) + +type UseGetGlobalConfigOptions = { + queryConfig?: QueryConfig +} + +export const useGetGlobalConfig = ({queryConfig}: UseGetGlobalConfigOptions = {}) => + useQuery({ + ...getGlobalConfigQueryOptions(), + ...queryConfig, + }) diff --git a/src/webui/features/analytics/api/set-analytics.ts b/src/webui/features/analytics/api/set-analytics.ts new file mode 100644 index 000000000..24ab27edb --- /dev/null +++ b/src/webui/features/analytics/api/set-analytics.ts @@ -0,0 +1,40 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import type {MutationConfig} from '../../../lib/react-query' + +import { + GlobalConfigEvents, + type GlobalConfigSetAnalyticsRequest, + type GlobalConfigSetAnalyticsResponse, +} from '../../../../shared/transport/events/global-config-events.js' +import {useTransportStore} from '../../../stores/transport-store' +import {getGlobalConfigQueryOptions} from './get-global-config' + +export const setAnalytics = ( + request: GlobalConfigSetAnalyticsRequest, +): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request( + GlobalConfigEvents.SET_ANALYTICS, + request, + ) +} + +type UseSetAnalyticsOptions = { + mutationConfig?: MutationConfig +} + +export const useSetAnalytics = ({mutationConfig}: UseSetAnalyticsOptions = {}) => { + const queryClient = useQueryClient() + const {onSuccess, ...rest} = mutationConfig ?? {} + + return useMutation({ + onSuccess(...args) { + queryClient.invalidateQueries({queryKey: getGlobalConfigQueryOptions().queryKey}) + onSuccess?.(...args) + }, + ...rest, + mutationFn: setAnalytics, + }) +} diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx new file mode 100644 index 000000000..16add8131 --- /dev/null +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -0,0 +1,130 @@ +import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@campfirein/byterover-packages/components/collapsible' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {Switch} from '@campfirein/byterover-packages/components/switch' +import {ChevronDown, ExternalLink, ShieldCheck} from 'lucide-react' +import {useState} from 'react' +import {toast} from 'sonner' + +import {formatError} from '../../../lib/error-messages' +import {noop} from '../../../lib/noop' +import {useGetGlobalConfig} from '../api/get-global-config' +import {useSetAnalytics} from '../api/set-analytics' +import {ANALYTICS_PRIVACY_URL} from '../constants' +import {DisableConfirmDialog} from './disable-confirm-dialog' +import {DisclosureDetails} from './disclosure-details' +import {EnableConfirmDialog} from './enable-confirm-dialog' + +export function AnalyticsPanel() { + const {data, error, isError, isLoading, refetch} = useGetGlobalConfig() + const setAnalytics = useSetAnalytics() + const [pendingIntent, setPendingIntent] = useState<'disable' | 'enable' | undefined>() + const [detailsOpen, setDetailsOpen] = useState(true) + + const analytics = data?.analytics ?? false + + function requestToggle(next: boolean) { + if (setAnalytics.isPending) return + if (analytics === next) return + setPendingIntent(next ? 'enable' : 'disable') + } + + async function applyChange(next: boolean) { + try { + await setAnalytics.mutateAsync({analytics: next}) + toast.success(next ? 'Analytics enabled.' : 'Analytics disabled.') + } catch (error_) { + toast.error(formatError(error_, 'Failed to update analytics setting.')) + } finally { + setPendingIntent(undefined) + } + } + + function handleDialogOpenChange(open: boolean) { + if (!open && !setAnalytics.isPending) setPendingIntent(undefined) + } + + return ( +
+
+

Analytics

+

+ Control how usage data is collected to improve Byterover. +

+
+ + {isError ? ( +

+ ✗ {formatError(error, 'Failed to load analytics state')} + {' · '} + +

+ ) : ( +
+
+
+ + Share usage analytics + + + Help us build a better Byterover by sharing your usage insights securely. + +
+ {isLoading ? ( + + ) : ( + + )} +
+ + + + + What data will be collected? + + {detailsOpen ? 'Hide details' : 'Show details'} + + + + + + + + + + + docs.byterover.dev/privacy + +
+ )} + + applyChange(true)} + onOpenChange={handleDialogOpenChange} + open={pendingIntent === 'enable'} + /> + applyChange(false)} + onOpenChange={handleDialogOpenChange} + open={pendingIntent === 'disable'} + /> +
+ ) +} diff --git a/src/webui/features/analytics/components/disable-confirm-dialog.tsx b/src/webui/features/analytics/components/disable-confirm-dialog.tsx new file mode 100644 index 000000000..81d18e6ed --- /dev/null +++ b/src/webui/features/analytics/components/disable-confirm-dialog.tsx @@ -0,0 +1,43 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@campfirein/byterover-packages/components/alert-dialog' +import {Button} from '@campfirein/byterover-packages/components/button' + +import {noop} from '../../../lib/noop' + +type Props = { + isPending: boolean + onConfirm: () => Promise + onOpenChange: (open: boolean) => void + open: boolean +} + +export function DisableConfirmDialog({isPending, onConfirm, onOpenChange, open}: Props) { + function fire() { + onConfirm().catch(noop) + } + + return ( + + + + Stop sharing usage analytics? + You can re-enable this at any time from this page. + + + + Cancel + + + + + ) +} diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx new file mode 100644 index 000000000..38414d4f4 --- /dev/null +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -0,0 +1,22 @@ +import {ANALYTICS_DISCLOSURE_SECTIONS} from '../constants' + +export function DisclosureDetails() { + return ( +
+ {ANALYTICS_DISCLOSURE_SECTIONS.map((section) => { + const Icon = section.icon + return ( +
+ +
+ + {section.label} + +

{section.body}

+
+
+ ) + })} +
+ ) +} diff --git a/src/webui/features/analytics/components/enable-confirm-dialog.tsx b/src/webui/features/analytics/components/enable-confirm-dialog.tsx new file mode 100644 index 000000000..4c913276f --- /dev/null +++ b/src/webui/features/analytics/components/enable-confirm-dialog.tsx @@ -0,0 +1,50 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@campfirein/byterover-packages/components/alert-dialog' +import {Button} from '@campfirein/byterover-packages/components/button' + +import {noop} from '../../../lib/noop' +import {DisclosureDetails} from './disclosure-details' + +type Props = { + isPending: boolean + onConfirm: () => Promise + onOpenChange: (open: boolean) => void + open: boolean +} + +export function EnableConfirmDialog({isPending, onConfirm, onOpenChange, open}: Props) { + function fire() { + onConfirm().catch(noop) + } + + return ( + + + + Share usage analytics with Byterover? + + Review what is collected before enabling. You can turn this off at any time. + + + +
+ +
+ + + Cancel + + +
+
+ ) +} diff --git a/src/webui/features/analytics/constants.ts b/src/webui/features/analytics/constants.ts new file mode 100644 index 000000000..0e5d2af7c --- /dev/null +++ b/src/webui/features/analytics/constants.ts @@ -0,0 +1,39 @@ +import type {LucideIcon} from 'lucide-react' + +import {Database, Eye, Link2, PowerOff, Server} from 'lucide-react' + +export type AnalyticsDisclosureSection = { + body: string + icon: LucideIcon + label: string +} + +export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy' + +export const ANALYTICS_DISCLOSURE_SECTIONS: readonly AnalyticsDisclosureSection[] = [ + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Database, + label: 'WHAT IS COLLECTED', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Eye, + label: 'WHICH SURFACES ARE TRACKED', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Server, + label: 'WHERE IT GOES', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Link2, + label: 'CROSS-DEVICE ALIAS', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: PowerOff, + label: 'HOW TO DISABLE', + }, +] as const diff --git a/src/webui/pages/configuration/layout.tsx b/src/webui/pages/configuration/layout.tsx index 2fbf57a3d..4d27621a7 100644 --- a/src/webui/pages/configuration/layout.tsx +++ b/src/webui/pages/configuration/layout.tsx @@ -13,6 +13,7 @@ const SECTIONS: readonly SectionDef[] = [ {end: true, label: 'General', path: '.'}, {label: 'Connectors', path: 'connectors'}, {label: 'Version control', path: 'version-control'}, + {label: 'Privacy', path: 'privacy'}, ] export function ConfigurationLayout() { diff --git a/src/webui/pages/configuration/privacy.tsx b/src/webui/pages/configuration/privacy.tsx new file mode 100644 index 000000000..074f6ad39 --- /dev/null +++ b/src/webui/pages/configuration/privacy.tsx @@ -0,0 +1,5 @@ +import {AnalyticsPanel} from '../../features/analytics/components/analytics-panel' + +export function PrivacySection() { + return +} diff --git a/src/webui/router.tsx b/src/webui/router.tsx index 858aca90b..39f353787 100644 --- a/src/webui/router.tsx +++ b/src/webui/router.tsx @@ -7,6 +7,7 @@ import {ChangesPage} from './pages/changes-page' import {ConnectorsSection} from './pages/configuration/connectors' import {GeneralSection} from './pages/configuration/general' import {ConfigurationLayout} from './pages/configuration/layout' +import {PrivacySection} from './pages/configuration/privacy' import {VersionControlSection} from './pages/configuration/version-control' import {ContextsPage} from './pages/contexts-page' import {HomePage} from './pages/home-page' @@ -36,6 +37,7 @@ export const router = createBrowserRouter([ {element: , index: true}, {element: , path: 'connectors'}, {element: , path: 'version-control'}, + {element: , path: 'privacy'}, ], element: , path: 'configuration', diff --git a/test/commands/locations.test.ts b/test/commands/locations.test.ts index 3602be4f4..aa7596a8a 100644 --- a/test/commands/locations.test.ts +++ b/test/commands/locations.test.ts @@ -9,6 +9,7 @@ import sinon, {restore, stub} from 'sinon' import type {ProjectLocationDTO} from '../../src/shared/transport/types/dto.js' import Locations from '../../src/oclif/commands/locations.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' // ==================== TestableLocationsCommand ==================== @@ -20,8 +21,10 @@ class TestableLocationsCommand extends Locations { this.mockConnector = mockConnector } - protected override async fetchLocations(): Promise { - return super.fetchLocations({ + protected override async fetchLocations( + cliMetadata: ReturnType, + ): Promise { + return super.fetchLocations(cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, diff --git a/test/commands/login.test.ts b/test/commands/login.test.ts index 920996bb8..1bdc12187 100644 --- a/test/commands/login.test.ts +++ b/test/commands/login.test.ts @@ -7,6 +7,7 @@ import {expect} from 'chai' import sinon, {restore, stub} from 'sinon' import Login, {type LoginOAuthOptions} from '../../src/oclif/commands/login.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' import { AuthEvents, type AuthLoginCompletedEvent, @@ -29,16 +30,22 @@ class TestableLoginCommand extends Login { return this.browserAvailable } - protected override async loginWithApiKey(apiKey: string): Promise { - return super.loginWithApiKey(apiKey, { + protected override async loginWithApiKey( + apiKey: string, + cliMetadata: ReturnType, + ): Promise { + return super.loginWithApiKey(apiKey, cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, }) } - protected override async loginWithOAuth(options?: LoginOAuthOptions): Promise { - return super.loginWithOAuth({ + protected override async loginWithOAuth( + cliMetadata: ReturnType, + options?: LoginOAuthOptions, + ): Promise { + return super.loginWithOAuth(cliMetadata, { ...options, maxRetries: 1, oauthTimeoutMs: 100, @@ -159,7 +166,7 @@ describe('Login Command', () => { expect(loggedMessages.some((m) => m.includes('Logged in as user@example.com'))).to.be.true }) - it('should send api key to transport handler', async () => { + it('should send api key + cli_metadata to transport handler', async () => { mockLoginResponse({success: true, userEmail: 'user@example.com'}) await createCommand('--api-key', 'my-secret-key').run() @@ -167,7 +174,9 @@ describe('Login Command', () => { expect((mockClient.requestWithAck as sinon.SinonStub).calledOnce).to.be.true const [event, data] = (mockClient.requestWithAck as sinon.SinonStub).firstCall.args expect(event).to.equal(AuthEvents.LOGIN_WITH_API_KEY) - expect(data).to.deep.equal({apiKey: 'my-secret-key'}) + // M13.3: payload now carries optional cli_metadata block alongside apiKey. + expect((data as {apiKey: string}).apiKey).to.equal('my-secret-key') + expect((data as {cli_metadata: {command_id: string}}).cli_metadata.command_id).to.equal('login') }) }) diff --git a/test/commands/logout.test.ts b/test/commands/logout.test.ts index 702b36673..07bc7dcf7 100644 --- a/test/commands/logout.test.ts +++ b/test/commands/logout.test.ts @@ -9,6 +9,7 @@ import sinon, {restore, stub} from 'sinon' import type {AuthLogoutResponse} from '../../src/shared/transport/events/auth-events.js' import Logout from '../../src/oclif/commands/logout.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' import {AuthEvents} from '../../src/shared/transport/events/auth-events.js' // ==================== TestableLogoutCommand ==================== @@ -21,8 +22,10 @@ class TestableLogoutCommand extends Logout { this.mockConnector = mockConnector } - protected override async performLogout(): Promise { - return super.performLogout({ + protected override async performLogout( + cliMetadata: ReturnType, + ): Promise { + return super.performLogout(cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, @@ -118,15 +121,19 @@ describe('Logout Command', () => { expect(loggedMessages.some((m) => m.includes('Logged out successfully'))).to.be.true }) - it('should send correct event to transport handler', async () => { + it('should send correct event to transport handler with cli_metadata payload', async () => { mockLogoutResponse({success: true}) await createCommand().run() expect(mockClient.requestWithAck.calledOnce).to.be.true - const [event, ...rest] = mockClient.requestWithAck.firstCall.args + const [event, payload] = mockClient.requestWithAck.firstCall.args expect(event).to.equal(AuthEvents.LOGOUT) - expect(rest).to.deep.equal([]) + // M13.3: oclif now attaches cli_metadata to every daemon-bound payload. + // The cli_metadata block is structurally validated by CliMetadataSchema in the daemon; + // here we just assert it is present with the expected shape (command_id is the most stable key). + expect(payload).to.be.an('object') + expect((payload as {cli_metadata: {command_id: string}}).cli_metadata.command_id).to.equal('logout') }) }) diff --git a/test/commands/settings/analytics-enabled.test.ts b/test/commands/settings/analytics-enabled.test.ts new file mode 100644 index 000000000..5a6b6856a --- /dev/null +++ b/test/commands/settings/analytics-enabled.test.ts @@ -0,0 +1,100 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import SettingsGet from '../../../src/oclif/commands/settings/get.js' +import {SettingsEvents} from '../../../src/shared/transport/events/settings-events.js' + +class TestableSettingsGet extends SettingsGet { + private readonly mockConnector: () => Promise + + public constructor(argv: string[], mockConnector: () => Promise, config: Config) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async fetchSetting(key: string) { + return super.fetchSetting(key, { + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + }) + } +} + +/** + * Smoke coverage for the post-M16.4 surface of `analytics.share`. + * + * The wire-shape behaviour, facade routing, and disclosure flow are + * exercised in depth by: + * - test/unit/infra/transport/handlers/settings-handler.test.ts + * - test/unit/oclif/lib/analytics-disclosure.test.ts + * + * This file only confirms the oclif command path resolves the key to + * the unified `settings:get` transport event — i.e. the legacy `brv + * analytics enable / disable` deletion does not leave the value + * unreachable via the CLI. + */ +describe('brv settings get analytics.share (M16.4 smoke)', () => { + let config: Config + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + let originalExitCode: number | string | undefined + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + originalExitCode = process.exitCode + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({ + category: 'analytics', + current: false, + default: false, + description: 'Send anonymous telemetry to ByteRover.', + key: 'analytics.share', + ok: true, + restartRequired: false, + type: 'boolean', + }), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + process.exitCode = originalExitCode + restore() + }) + + it('routes to the SettingsEvents.GET transport event with key=analytics.share', async () => { + const command = new TestableSettingsGet(['analytics.share'], mockConnector, config) + stub(command, 'log').callsFake(() => {}) + await command.run() + + const calls = (mockClient.requestWithAck as sinon.SinonStub).getCalls() + expect(calls.length, 'one requestWithAck call').to.equal(1) + expect(calls[0].args[0]).to.equal(SettingsEvents.GET) + expect(calls[0].args[1]).to.deep.equal({key: 'analytics.share'}) + }) +}) diff --git a/test/commands/settings/analytics-status.test.ts b/test/commands/settings/analytics-status.test.ts new file mode 100644 index 000000000..619bdf8db --- /dev/null +++ b/test/commands/settings/analytics-status.test.ts @@ -0,0 +1,107 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import SettingsGet from '../../../src/oclif/commands/settings/get.js' +import {SettingsEvents} from '../../../src/shared/transport/events/settings-events.js' + +class TestableSettingsGet extends SettingsGet { + private readonly mockConnector: () => Promise + + public constructor(argv: string[], mockConnector: () => Promise, config: Config) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async fetchSetting(key: string) { + return super.fetchSetting(key, { + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + }) + } +} + +/** + * Smoke coverage for the post-M16.4 surface of `analytics.status`. + * + * The text + JSON parity and the snapshot composition are exercised by: + * - test/unit/shared/utils/format-analytics-status.test.ts + * - test/unit/server/infra/analytics/build-status-snapshot.test.ts + * - test/unit/infra/transport/handlers/settings-handler.test.ts + * + * This file only confirms the oclif command path resolves the key to + * the unified `settings:get` transport event — i.e. the legacy + * `brv analytics status` deletion does not leave the value unreachable + * via the CLI. + */ +describe('brv settings get analytics.status (M16.4 smoke)', () => { + let config: Config + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + let originalExitCode: number | string | undefined + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + originalExitCode = process.exitCode + + const snapshot = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy' as const}, + droppedCount: 0, + enabled: false, + endpoint: 'https://telemetry-dev.byterover.dev', + queueDepth: 0, + } + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({ + category: 'analytics', + current: snapshot, + description: 'Live analytics shipping snapshot', + key: 'analytics.status', + ok: true, + restartRequired: false, + type: 'readonly-info', + }), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + process.exitCode = originalExitCode + restore() + }) + + it('routes to the SettingsEvents.GET transport event with key=analytics.status', async () => { + const command = new TestableSettingsGet(['analytics.status'], mockConnector, config) + stub(command, 'log').callsFake(() => {}) + await command.run() + + const calls = (mockClient.requestWithAck as sinon.SinonStub).getCalls() + expect(calls.length, 'one requestWithAck call').to.equal(1) + expect(calls[0].args[0]).to.equal(SettingsEvents.GET) + expect(calls[0].args[1]).to.deep.equal({key: 'analytics.status'}) + }) +}) diff --git a/test/commands/settings/set.test.ts b/test/commands/settings/set.test.ts index 4364d3613..61e4dfc65 100644 --- a/test/commands/settings/set.test.ts +++ b/test/commands/settings/set.test.ts @@ -82,6 +82,7 @@ describe('brv settings set', () => { let config: Config let loggedMessages: string[] let stdoutOutput: string[] + let warnMessages: string[] let mockClient: sinon.SinonStubbedInstance let mockConnector: sinon.SinonStub<[], Promise> let originalExitCode: number | string | undefined @@ -93,6 +94,7 @@ describe('brv settings set', () => { beforeEach(() => { loggedMessages = [] stdoutOutput = [] + warnMessages = [] originalExitCode = process.exitCode mockClient = { @@ -127,6 +129,10 @@ describe('brv settings set', () => { stub(command, 'log').callsFake((msg?: string) => { if (msg !== undefined) loggedMessages.push(msg) }) + stub(command, 'warn').callsFake((msg: Error | string) => { + warnMessages.push(typeof msg === 'string' ? msg : msg.message) + return msg as Error & string + }) return command } @@ -135,6 +141,10 @@ describe('brv settings set', () => { stub(command, 'log').callsFake((msg?: string) => { if (msg !== undefined) loggedMessages.push(msg) }) + stub(command, 'warn').callsFake((msg: Error | string) => { + warnMessages.push(typeof msg === 'string' ? msg : msg.message) + return msg as Error & string + }) stub(process.stdout, 'write').callsFake((chunk: string | Uint8Array) => { stdoutOutput.push(String(chunk)) return true @@ -402,4 +412,80 @@ describe('brv settings set', () => { expect(output).to.match(/Run `brv restart` to apply/) }) }) + + describe('--yes flag scope (bot review #2)', () => { + it('warns when --yes is passed for a key other than analytics.share', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeGetResponse('agentPool.maxSize', 10) + if (event === SettingsEvents.SET) return {ok: true, restartRequired: true} + throw new Error('unexpected event') + }) + + await createCommand('agentPool.maxSize', '25', '-y').run() + + // SET still proceeds — the warning is informational, not a refusal. + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCall = requestStub.getCalls().find((c) => c.args[0] === SettingsEvents.SET) + expect(setCall, 'SET dispatched even with stray --yes').to.exist + + const warn = warnMessages.join('\n') + expect(warn).to.match(/--yes/) + expect(warn).to.match(/analytics\.share/) + }) + + it('does NOT warn when --yes is passed for analytics.share', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.share', false) + if (event === SettingsEvents.SET) return {ok: true, restartRequired: false} + throw new Error('unexpected event') + }) + + await createCommand('analytics.share', 'true', '-y').run() + + expect(warnMessages, 'no leaky-flag warning on the analytics key').to.deep.equal([]) + }) + }) + + describe('--format json + interactive consent (bot review #3)', () => { + it('refuses to prompt in JSON mode without --yes and emits a requires_consent error envelope', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.share', false) + if (event === SettingsEvents.SET) { + throw new Error('SET must not be dispatched when consent is required and refused') + } + + throw new Error('unexpected event') + }) + + await createJsonCommand('analytics.share', 'true').run() + + const json = parseJsonOutput() + expect(json.command).to.equal('settings set') + expect(json.success).to.be.false + const {error} = json.data as {error: {code: string; key: string; message: string}} + expect(error.code).to.equal('requires_consent') + expect(error.key).to.equal('analytics.share') + expect(error.message.toLowerCase()).to.match(/--yes|disclosure/) + expect(process.exitCode).to.equal(1) + + // The disclosure markdown MUST NOT have been printed (would pollute stdout + // and break the JSON envelope). + const logged = loggedMessages.join('\n') + expect(logged, 'disclosure markdown leaked to stdout in JSON mode').to.not.match(/analytics disclosure/i) + }) + + it('passes through in JSON mode WITH --yes (consent gate satisfied silently)', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.share', false) + if (event === SettingsEvents.SET) return {ok: true, restartRequired: false} + throw new Error('unexpected event') + }) + + await createJsonCommand('analytics.share', 'true', '-y').run() + + const json = parseJsonOutput() + expect(json.success).to.be.true + expect(process.exitCode ?? 0).to.equal(0) + }) + }) }) diff --git a/test/commands/status.test.ts b/test/commands/status.test.ts index 97b5565d8..2e2579151 100644 --- a/test/commands/status.test.ts +++ b/test/commands/status.test.ts @@ -12,6 +12,7 @@ import sinon, {restore, stub} from 'sinon' import type {StatusDTO} from '../../src/shared/transport/types/dto.js' import Status from '../../src/oclif/commands/status.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' // ==================== TestableStatusCommand ==================== @@ -23,8 +24,8 @@ class TestableStatusCommand extends Status { this.mockConnector = mockConnector } - protected override async fetchStatus(): Promise { - return super.fetchStatus({ + protected override async fetchStatus(cliMetadata: ReturnType): Promise { + return super.fetchStatus(cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts new file mode 100644 index 000000000..f0fd06180 --- /dev/null +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -0,0 +1,569 @@ +/* eslint-disable camelcase, no-await-in-loop */ +/** + * M4.7 (ENG-2649) end-to-end smoke for the analytics pipeline against a + * real backend. Replaces the prior shell + .mjs harness with a single + * mocha file that still drives the real `brv` binary, real daemon, and + * real HTTP path - so PASS here still means "the backend accepted the + * batch with 2xx". + * + * Not picked up by `npm test` (glob is "test/**\/*.test.ts"); run via + * `npm run test:e2e:analytics`. The `pretest:e2e:analytics` npm hook + * runs `npm run build` automatically so `dist/` is fresh; no `npm link` + * is required because the test spawns `bin/run.js` directly from the + * repo (so the harness always exercises THIS checkout, never a globally + * linked one). + * + * Per-scenario isolation uses a temp `BRV_DATA_DIR` + temp `HOME`, and + * `brv restart` (with the scenario's env) is used for teardown so it + * properly cleans the SCENARIO's daemon (`bin/kill-daemon.js` alone + * would read the user's real global daemon.json and leak scenario + * daemons to the process table). + * + * Sequential by design. Do NOT run with `--parallel`: scenarios mutate + * `process.env.BRV_DATA_DIR` / `HOME` inside `emitEvents` and restore + * in `finally`. Parallel runs would corrupt each other. + */ + +import {expect} from 'chai' +import {formatISO} from 'date-fns' +import {spawnSync} from 'node:child_process' +import {randomUUID} from 'node:crypto' +import {existsSync, mkdtempSync, readFileSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {createServer as createHttpServer, type Server as HttpServer} from 'node:http' +import {createServer, type Server as NetServer} from 'node:net' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {resolveLocalServerMainPath} from '../../../src/server/utils/server-main-resolver.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, '..', '..', '..') +const BRV_BIN = join(REPO_ROOT, 'bin', 'run.js') +const DEFAULT_BACKEND = process.env.BRV_ANALYTICS_BASE_URL ?? 'https://telemetry-dev.byterover.dev' +const DEFAULT_IAM = process.env.BRV_IAM_BASE_URL ?? 'https://dev-beta-iam.byterover.dev' +const DIST_DAEMON = join(REPO_ROOT, 'dist', 'server', 'infra', 'daemon', 'brv-server.js') + +type ScenarioEnv = { + dataDir: string + env: NodeJS.ProcessEnv + home: string +} + +type JsonlRow = { + attempts?: number + id?: string + identity?: {device_id?: string; user_id?: string} + name?: string + status?: 'failed' | 'pending' | 'sent' +} + +function sleep(ms: number): Promise { + return new Promise((res) => { + setTimeout(res, ms) + }) +} + +function makeScenarioEnv(overrideBackend?: string): ScenarioEnv { + const dataDir = mkdtempSync(join(tmpdir(), 'brv-e2e-')) + const home = mkdtempSync(join(tmpdir(), 'brv-home-')) + return { + dataDir, + env: { + ...process.env, + BRV_ANALYTICS_BASE_URL: overrideBackend ?? DEFAULT_BACKEND, + BRV_DATA_DIR: dataDir, + BRV_ENV: 'development', + BRV_IAM_BASE_URL: DEFAULT_IAM, + HOME: home, + }, + home, + } +} + +function runBrv(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 30_000): {ok: boolean; reason?: string} { + // process.execPath (the current node) + bin/run.js avoids depending on + // `npm link` and always exercises THIS checkout. The scenario env carries + // BRV_DATA_DIR / HOME / BRV_ANALYTICS_BASE_URL — all the toggles brv reads. + const result = spawnSync(process.execPath, [BRV_BIN, ...args], {env, stdio: 'ignore', timeout: timeoutMs}) + if (result.error) return {ok: false, reason: `brv ${args.join(' ')} failed to spawn: ${result.error.message}`} + if (result.status !== 0) return {ok: false, reason: `brv ${args.join(' ')} exit ${result.status}`} + return {ok: true} +} + +/** + * Tears down the daemon for `env.BRV_DATA_DIR` via `brv restart`, which: + * 1. kills brv client procs (TUI/MCP/headless), + * 2. SIGTERM/SIGKILL the daemon registered in `${BRV_DATA_DIR}/daemon.json`, + * 3. pattern-kills orphan brv-server.js / agent-process.js procs, + * 4. removes daemon.json / heartbeat / spawn-lock from the scoped data dir. + * Returning `void` because failures are non-fatal: the temp dir is about to + * be rm -rf'd anyway, and the next scenario boots into its own fresh dir. + */ +function restartBrv(env: NodeJS.ProcessEnv): void { + spawnSync(process.execPath, [BRV_BIN, 'restart'], {env, stdio: 'ignore', timeout: 30_000}) +} + +function bootDaemon(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { + return runBrv(['status'], env) +} + +function enableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { + return runBrv(['settings', 'set', 'analytics.share', 'true', '--yes'], env) +} + +function disableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { + return runBrv(['settings', 'set', 'analytics.share', 'false'], env) +} + +function jsonlPath(dataDir: string): string { + return join(dataDir, 'analytics-queue.jsonl') +} + +function readRows(path: string): JsonlRow[] { + if (!existsSync(path)) return [] + const content = readFileSync(path, 'utf8') + const rows: JsonlRow[] = [] + for (const line of content.split('\n')) { + if (line.length === 0) continue + rows.push(JSON.parse(line) as JsonlRow) + } + + return rows +} + +function countStatus(path: string, status: 'failed' | 'pending' | 'sent'): number { + return readRows(path).filter((r) => r.status === status).length +} + +async function waitFor(predicate: () => boolean, timeoutMs: number, intervalMs = 1000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await sleep(intervalMs) + } + + return predicate() +} + +async function waitForStatus(path: string, target: 'failed' | 'pending' | 'sent', timeoutMs: number): Promise { + return waitFor(() => { + const rows = readRows(path) + const last = rows.at(-1) + return last !== undefined && last.status === target + }, timeoutMs) +} + +/** + * Emits `count` cli_invocation events via `analytics:track` against the + * daemon under `env.BRV_DATA_DIR`. Temporarily mutates `process.env` + * because `connectToDaemon` reads it for instance discovery, then + * restores in `finally`. Safe because mocha runs `describe`/`it` + * sequentially - do NOT add `--parallel`. + */ +async function emitEvents(count: number, env: NodeJS.ProcessEnv): Promise<{failed: number; succeeded: number}> { + const prev = { + BRV_ANALYTICS_BASE_URL: process.env.BRV_ANALYTICS_BASE_URL, + BRV_DATA_DIR: process.env.BRV_DATA_DIR, + BRV_ENV: process.env.BRV_ENV, + BRV_IAM_BASE_URL: process.env.BRV_IAM_BASE_URL, + HOME: process.env.HOME, + } + process.env.BRV_ANALYTICS_BASE_URL = env.BRV_ANALYTICS_BASE_URL + process.env.BRV_DATA_DIR = env.BRV_DATA_DIR + process.env.BRV_ENV = env.BRV_ENV + process.env.BRV_IAM_BASE_URL = env.BRV_IAM_BASE_URL + process.env.HOME = env.HOME + try { + const {connectToDaemon} = await import('@campfirein/brv-transport-client') + const {client} = await connectToDaemon({ + clientType: 'cli', + fromDir: REPO_ROOT, + projectPath: REPO_ROOT, + serverPath: resolveLocalServerMainPath(), + }) + const now = Date.now() + let succeeded = 0 + let failed = 0 + for (let i = 0; i < count; i++) { + try { + await client.requestWithAck('analytics:track', { + event: 'cli_invocation', + properties: { + client_sent_at: now + i, + command_id: `e2e-${randomUUID().slice(0, 8)}-${i}`, + flag_names: [], + is_ci: false, + is_tty: false, + package_manager: 'npm', + runtime: 'node', + }, + }) + succeeded += 1 + } catch { + failed += 1 + } + } + + await client.disconnect() + return {failed, succeeded} + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +async function startDropProxy(): Promise<{close: () => Promise; port: number}> { + return new Promise((res, rej) => { + const server: NetServer = createServer((socket) => socket.destroy()) + server.on('error', rej) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr === null || typeof addr === 'string') { + rej(new Error('drop-proxy: unexpected address shape')) + return + } + + res({ + close: () => + new Promise((closeRes) => { + server.close(() => closeRes()) + }), + port: addr.port, + }) + }) + }) +} + +/** + * Minimal HTTP backend that responds 200 to anything. Used as Phase B of + * scenario 5 to simulate "backend came back up" on the same port the + * drop-proxy was using - so the daemon (still pointed at that URL) sees + * a successful flush and the M4.5 backoff policy resets + * `consecutive_failures` to 0. + */ +async function startAcceptProxy(port: number): Promise<{close: () => Promise}> { + return new Promise((res, rej) => { + const server: HttpServer = createHttpServer((_req, response) => { + response.writeHead(200, {'content-type': 'application/json'}) + response.end('{"ok":true}') + }) + server.on('error', rej) + server.listen(port, '127.0.0.1', () => { + res({ + close: () => + new Promise((closeRes) => { + server.close(() => closeRes()) + }), + }) + }) + }) +} + +function analyticsStatusJson(env: NodeJS.ProcessEnv): Record | undefined { + const result = spawnSync(process.execPath, [BRV_BIN, 'settings', 'get', 'analytics.status', '--format', 'json'], { + env, + timeout: 15_000, + }) + if (result.status !== 0) return undefined + try { + return JSON.parse(result.stdout.toString()) as Record + } catch { + return undefined + } +} + +function readBackoffFailures(env: NodeJS.ProcessEnv): {failures: number; state: string} { + const status = analyticsStatusJson(env) + const backoff = (status?.data as undefined | {backoff?: Record})?.backoff + return { + failures: (backoff?.consecutive_failures as number | undefined) ?? -1, + state: (backoff?.state as string | undefined) ?? 'unknown', + } +} + +async function preflightBackend(url: string): Promise<{ok: boolean; reason?: string}> { + // Wire format: per-event `created_at` (ISO 8601 with offset), + // `schema_version: 2`, no numeric `timestamp` field. If the backend + // still validates against the legacy v1 `{timestamp: number}` shape or + // rejects this shape via `forbidNonWhitelisted`, every scenario would + // FAIL with retry-cap exhaustion; better to skip the suite up-front + // with a clear reason. + const body = JSON.stringify({ + events: [ + { + created_at: formatISO(new Date()), + identity: {device_id: 'e2e-preflight'}, + name: 'daemon_start', + properties: { + cli_version: '3.12.0', + environment: 'development', + node_version: process.version, + os: process.platform, + }, + }, + ], + schema_version: 2, + }) + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 8000) + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const res = await fetch(`${url}/v1/events`, { + body, + headers: {'content-type': 'application/json', 'x-byterover-device-id': 'e2e-preflight'}, + method: 'POST', + signal: ctrl.signal, + }) + clearTimeout(timer) + if (res.status >= 200 && res.status < 300) return {ok: true} + if (res.status === 400) { + return { + ok: false, + reason: `backend at ${url} returned 400 to the created_at wire format - likely not yet deployed`, + } + } + + return {ok: false, reason: `unexpected preflight status ${res.status} from ${url}`} + } catch (error) { + return {ok: false, reason: `preflight unreachable: ${(error as Error).message}`} + } +} + +describe('M4.7 analytics e2e (real CLI, real daemon, real backend)', function () { + this.timeout(600_000) + + const cleanupDirs: string[] = [] + let currentScenario: ScenarioEnv | undefined + + before(async function () { + if (!existsSync(BRV_BIN)) { + console.log(`[M4.7 e2e] ${BRV_BIN} missing - run \`npm install\` first. Skipping suite.`) + this.skip() + } + + if (!existsSync(DIST_DAEMON)) { + console.log( + `[M4.7 e2e] ${DIST_DAEMON} missing - run \`npm run build\` first ` + + `(the npm script does this automatically via pretest:e2e:analytics). Skipping suite.`, + ) + this.skip() + } + + const pre = await preflightBackend(DEFAULT_BACKEND) + if (!pre.ok) { + console.log(`[M4.7 e2e] preflight failed: ${pre.reason}. Skipping suite.`) + this.skip() + } + }) + + afterEach(async () => { + // Tear down the SCENARIO's daemon (scoped via env). Skipping this would + // leak a node process per scenario - bin/kill-daemon.js without scoped env + // would read the user's real ~/Library/.../daemon.json instead. + if (currentScenario !== undefined) { + restartBrv(currentScenario.env) + currentScenario = undefined + } + + await sleep(500) + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop() + if (dir !== undefined && existsSync(dir)) { + await rm(dir, {force: true, recursive: true}) + } + } + }) + + describe('1 happy', () => { + it('opt-in + 1 event ships within 35s', async () => { + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + const emit = await emitEvents(1, scenario.env) + expect(emit.failed, 'emit failures').to.equal(0) + const ok = await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 35_000) + expect(ok, `timeout waiting for status=sent in ${jsonlPath(scenario.dataDir)}`).to.equal(true) + }) + }) + + describe('2 burst', () => { + it('25 events trigger the 20-event threshold flush', async function () { + this.timeout(120_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + const emit = await emitEvents(25, scenario.env) + expect(emit.failed, 'emit failures').to.equal(0) + // 20-event threshold ships first batch immediately; periodic 30s + // tick catches stragglers. Wait up to 60s for >= 25 sent. + const ok = await waitFor(() => countStatus(jsonlPath(scenario.dataDir), 'sent') >= 25, 60_000, 2000) + const sent = countStatus(jsonlPath(scenario.dataDir), 'sent') + const pending = countStatus(jsonlPath(scenario.dataDir), 'pending') + expect(ok, `only ${sent} sent after 60s (pending=${pending})`).to.equal(true) + }) + }) + + describe('3 idle', () => { + it('1 event ships via the 30s interval timer', async function () { + this.timeout(90_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + const emit = await emitEvents(1, scenario.env) + expect(emit.failed, 'emit failures').to.equal(0) + const ok = await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 45_000) + expect(ok, 'timeout waiting for interval-driven flush').to.equal(true) + }) + }) + + describe('4 transition (manual brv login)', () => { + const enabled = process.env.BRV_E2E_TRANSITION === '1' + const itMaybe = enabled ? it : it.skip + itMaybe('anon -> brv login -> authed, both ship with correct identity', async function () { + this.timeout(360_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + expect(await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 35_000), 'anon event did not ship').to.equal(true) + + console.log('\n[M4.7 e2e] transition: anon event shipped. NOW run this exact command in another terminal:\n') + console.log( + ` HOME='${scenario.home}' BRV_DATA_DIR='${scenario.dataDir}' ` + + `BRV_IAM_BASE_URL='${DEFAULT_IAM}' BRV_ENV=development ` + + `node '${BRV_BIN}' login\n`, + ) + console.log('[M4.7 e2e] waiting up to 5 minutes for credentials to appear...\n') + + const credentialsPath = join(scenario.dataDir, 'credentials') + const loggedIn = await waitFor(() => existsSync(credentialsPath), 300_000, 2000) + expect(loggedIn, 'no login detected within 5 minutes').to.equal(true) + + // Pre-hook flushes anon batch; post-hook clears the on-disk queue. + // Wait for that to settle before emitting the authed event. + await sleep(10_000) + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + expect( + await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 45_000), + 'post-login event did not ship', + ).to.equal(true) + const rows = readRows(jsonlPath(scenario.dataDir)) + const last = rows.at(-1) + const userId = last?.identity?.user_id ?? '' + expect(userId, 'post-login event must carry a user_id').to.be.a('string').and.have.length.greaterThan(0) + }) + }) + + describe('5 down (drop-proxy)', () => { + let dropProxy: undefined | {close: () => Promise; port: number} + let acceptProxy: undefined | {close: () => Promise} + + afterEach(async () => { + if (acceptProxy) { + await acceptProxy.close() + acceptProxy = undefined + } + + if (dropProxy) { + await dropProxy.close() + dropProxy = undefined + } + }) + + it('failed flush advances backoff counters AND backend-up resets them', async function () { + // Phase A (~35s drop wait) + Phase B (~35s accept wait) + boot/emit + slack. + this.timeout(180_000) + + // -------- Phase A: backend down -------- + dropProxy = await startDropProxy() + const {port} = dropProxy + const backend = `http://127.0.0.1:${port}` + const scenario = makeScenarioEnv(backend) + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + + // One 30s tick + slack for retry classification. + await sleep(35_000) + + const rows = readRows(jsonlPath(scenario.dataDir)) + const tracked = rows.filter((r) => r.name === 'cli_invocation') + let maxAttempts = 0 + for (const row of tracked) { + if ((row.attempts ?? 0) > maxAttempts) maxAttempts = row.attempts ?? 0 + } + + expect(maxAttempts, 'daemon never attempted to flush against drop-proxy').to.be.greaterThan(0) + const downState = readBackoffFailures(scenario.env) + expect( + downState.failures, + `expected backoff.consecutive_failures > 0 (state=${downState.state})`, + ).to.be.greaterThan(0) + + // -------- Phase B: backend back up, observe recovery -------- + // Free the port so the accept-proxy can bind it. Daemon URL doesn't + // change - the next flush tick will hit the new server on same port. + await dropProxy.close() + dropProxy = undefined + acceptProxy = await startAcceptProxy(port) + + // Emit one fresh event so the queue is non-empty after Phase A wipes. + // M4.5 backoff is exponential, so a single 30s tick may not trigger + // the next retry attempt - poll up to 90s for `consecutive_failures` + // to drop back to 0 (covers up to ~3 backoff windows). + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + const recovered = await waitFor(() => readBackoffFailures(scenario.env).failures === 0, 90_000, 2000) + + const upState = readBackoffFailures(scenario.env) + expect( + recovered, + `expected backoff.consecutive_failures to reset to 0 within 90s (was ${downState.failures}, now ${upState.failures}, state=${upState.state})`, + ).to.equal(true) + const sentAfter = countStatus(jsonlPath(scenario.dataDir), 'sent') + expect(sentAfter, 'expected >=1 sent row after backend recovery').to.be.at.least(1) + }) + }) + + describe('6 disable mid-flight', () => { + it('post-disable events stay pending, no further ships', async function () { + this.timeout(120_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + expect(await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 35_000), 'baseline event did not ship').to.equal( + true, + ) + const sentBefore = countStatus(jsonlPath(scenario.dataDir), 'sent') + + expect(disableAnalytics(scenario.env)).to.deep.include({ok: true}) + + // After disable, `analytics:track` still writes to JSONL but the + // flush scheduler is gated, so status should stay pending. + expect((await emitEvents(5, scenario.env)).failed).to.equal(0) + await sleep(35_000) + + const sentAfter = countStatus(jsonlPath(scenario.dataDir), 'sent') + const pendingAfter = countStatus(jsonlPath(scenario.dataDir), 'pending') + expect(sentAfter, `expected no new sends after disable (was ${sentBefore})`).to.equal(sentBefore) + expect(pendingAfter, 'expected >= 5 pending rows after disable').to.be.at.least(5) + }) + }) +}) diff --git a/test/e2e/analytics/lifecycle-db.e2e.ts b/test/e2e/analytics/lifecycle-db.e2e.ts new file mode 100644 index 000000000..b2129d7ca --- /dev/null +++ b/test/e2e/analytics/lifecycle-db.e2e.ts @@ -0,0 +1,608 @@ +/* eslint-disable camelcase, no-await-in-loop */ +/** + * M14 / M15.6 database-roundtrip e2e — drives a real brv daemon, ships + * task-lifecycle events over real HTTP to a local telemetry instance, + * and queries postgres `raw_events` to verify rows landed. + * + * Different from `lifecycle-wire.e2e.ts`: + * - lifecycle-wire stops at the daemon's HTTP request body (in-process + * capture stub). Proves the CLI side of the pipeline. + * - this file goes one more step — through a real telemetry process and + * into postgres. Proves the FULL chain from `brv curate` to row-in-db + * AND that telemetry promotes the documented super-props into top-level + * columns (cli_version / os / node_version / environment / schema_version). + * + * Requires (skips suite if any missing): + * - `docker ps` shows `byterover-telemetry-telemetry-1` listening on 3000 + * (the byterover-telemetry repo's `docker compose up`). + * - `docker ps` shows `byterover-telemetry-postgres-1` listening on 54329. + * - `raw_events` table exists in postgres. If telemetry was just brought + * up, run migrations once via: + * docker exec byterover-telemetry-telemetry-1 sh -c \ + * 'cd /app && node_modules/.bin/typeorm migration:run \ + * -d dist/infrastructure/persistence/typeorm/data-source.js' + * + * Run via `npm run test:e2e:db`. Not picked up by `npm test`. + * Sequential by design — each `it()` mutates `process.env`. + */ + +import {expect} from 'chai' +import {spawnSync} from 'node:child_process' +import {existsSync, mkdtempSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {resolveLocalServerMainPath} from '../../../src/server/utils/server-main-resolver.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, '..', '..', '..') +const BRV_BIN = join(REPO_ROOT, 'bin', 'run.js') +const DIST_DAEMON = join(REPO_ROOT, 'dist', 'server', 'infra', 'daemon', 'brv-server.js') + +const TELEMETRY_URL = process.env.E2E_TELEMETRY_URL ?? 'http://localhost:3000' +const POSTGRES_CONTAINER = process.env.E2E_POSTGRES_CONTAINER ?? 'byterover-telemetry-postgres-1' +const POSTGRES_USER = 'telemetry' +const POSTGRES_DB = 'telemetry_test' +const STUB_IAM = process.env.BRV_IAM_BASE_URL ?? 'https://dev-beta-iam.byterover.dev' + +type ScenarioEnv = { + dataDir: string + env: NodeJS.ProcessEnv + home: string +} + +/** + * Per-row projection used for the cheap event-name / task-type / device-id + * assertions. Columns map 1:1 to `raw_events` schema (see + * byterover-telemetry/.../raw-event.typeorm.entity.ts). + */ +type RawEventRow = { + cli_version: string + client_timestamp: string + device_id: string + environment: string + event_name: string + failure_kind: null | string + node_version: string + os: string + outcome: null | string + properties_json: string + received_at: string + schema_version: number + task_id: string + task_type: string + user_id: null | string +} + +function sleep(ms: number): Promise { + return new Promise((res) => { + setTimeout(res, ms) + }) +} + +function makeScenarioEnv(): ScenarioEnv { + const dataDir = mkdtempSync(join(tmpdir(), 'brv-e2e-db-')) + const home = mkdtempSync(join(tmpdir(), 'brv-home-')) + return { + dataDir, + env: { + ...process.env, + BRV_ANALYTICS_BASE_URL: TELEMETRY_URL, + BRV_DATA_DIR: dataDir, + BRV_ENV: 'development', + BRV_IAM_BASE_URL: STUB_IAM, + HOME: home, + }, + home, + } +} + +function runBrv(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 30_000): {ok: boolean; reason?: string} { + const result = spawnSync(process.execPath, [BRV_BIN, ...args], {env, stdio: 'ignore', timeout: timeoutMs}) + if (result.error) return {ok: false, reason: `brv ${args.join(' ')} failed: ${result.error.message}`} + if (result.status !== 0) return {ok: false, reason: `brv ${args.join(' ')} exit ${result.status}`} + return {ok: true} +} + +function restartBrv(env: NodeJS.ProcessEnv): void { + spawnSync(process.execPath, [BRV_BIN, 'restart'], {env, stdio: 'ignore', timeout: 30_000}) +} + +async function waitFor(predicate: () => Promise, timeoutMs: number, intervalMs = 1000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await predicate()) return true + await sleep(intervalMs) + } + + return predicate() +} + +/** + * Execute SQL against the postgres container via `docker exec`. Avoids a + * host-side `psql` dependency; the container already has it baked in. + * `-t -A` strips header + alignment so each row is a single tab-delimited + * line we can split safely. + */ +function execSql(sql: string): {ok: boolean; output: string; reason?: string} { + const result = spawnSync( + 'docker', + ['exec', '-i', POSTGRES_CONTAINER, 'psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-t', '-A', '-F', '\t', '-c', sql], + {encoding: 'utf8', timeout: 10_000}, + ) + if (result.error) return {ok: false, output: '', reason: result.error.message} + if (result.status !== 0) return {ok: false, output: result.stderr.trim(), reason: `psql exit ${result.status}`} + return {ok: true, output: result.stdout.trim()} +} + +/** + * Query raw_events for rows matching a task_id. Returns one row per event + * (task_created, task_failed, curate_run_completed, etc) with both the + * promoted columns (cli_version / os / ...) and the raw properties JSONB + * for deep inspection. + */ +function fetchEvents(taskIdLike: string): RawEventRow[] { + const sql = ` + SELECT event_name, + properties->>'task_id' AS task_id, + properties->>'task_type' AS task_type, + COALESCE(properties->>'failure_kind', '') AS failure_kind, + COALESCE(properties->>'outcome', '') AS outcome, + identity_device_id AS device_id, + COALESCE(identity_user_id, '') AS user_id, + cli_version, + os, + node_version, + environment, + schema_version::text, + client_timestamp::text, + received_at::text, + properties::text + FROM raw_events + WHERE properties->>'task_id' = '${taskIdLike}' + ORDER BY received_at + ` + const result = execSql(sql) + if (!result.ok) return [] + if (result.output.length === 0) return [] + const rows: RawEventRow[] = [] + for (const line of result.output.split('\n')) { + const [ + event_name, + task_id, + task_type, + failure_kind, + outcome, + device_id, + user_id, + cli_version, + os, + node_version, + environment, + schema_version, + client_timestamp, + received_at, + properties_json, + ] = line.split('\t') + rows.push({ + cli_version, + client_timestamp, + device_id, + environment, + event_name, + failure_kind: failure_kind === '' ? null : failure_kind, + node_version, + os, + outcome: outcome === '' ? null : outcome, + properties_json, + received_at, + schema_version: Number.parseInt(schema_version, 10), + task_id, + task_type, + user_id: user_id === '' ? null : user_id, + }) + } + + return rows +} + +async function checkTelemetryReachable(): Promise { + try { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const res = await fetch(`${TELEMETRY_URL}/health`, {signal: AbortSignal.timeout(3000)}) + return res.status === 200 + } catch { + return false + } +} + +function checkPostgresReachable(): boolean { + return execSql('SELECT 1').ok +} + +function checkRawEventsTableExists(): boolean { + const result = execSql("SELECT to_regclass('public.raw_events')") + if (!result.ok) return false + // to_regclass returns the table name when found, empty string when not. + return result.output.trim().length > 0 && result.output.trim() !== '' +} + +async function fireCreateAndCancel( + env: NodeJS.ProcessEnv, + task: {content: string; projectPath?: string; taskId: string; type: string}, +): Promise { + const prev = { + BRV_ANALYTICS_BASE_URL: process.env.BRV_ANALYTICS_BASE_URL, + BRV_DATA_DIR: process.env.BRV_DATA_DIR, + BRV_ENV: process.env.BRV_ENV, + BRV_IAM_BASE_URL: process.env.BRV_IAM_BASE_URL, + HOME: process.env.HOME, + } + process.env.BRV_ANALYTICS_BASE_URL = env.BRV_ANALYTICS_BASE_URL + process.env.BRV_DATA_DIR = env.BRV_DATA_DIR + process.env.BRV_ENV = env.BRV_ENV + process.env.BRV_IAM_BASE_URL = env.BRV_IAM_BASE_URL + process.env.HOME = env.HOME + try { + const {connectToDaemon} = await import('@campfirein/brv-transport-client') + const {client} = await connectToDaemon({ + clientType: 'cli', + fromDir: REPO_ROOT, + projectPath: task.projectPath ?? REPO_ROOT, + serverPath: resolveLocalServerMainPath(), + }) + + await client.requestWithAck('task:create', { + content: task.content, + projectPath: task.projectPath ?? REPO_ROOT, + taskId: task.taskId, + type: task.type, + }) + await sleep(50) + await client.requestWithAck('task:cancel', {taskId: task.taskId}) + await client.disconnect() + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +/** + * Common per-row sanity: every row carries the same shape regardless of + * event_name. Promoted columns are populated from super-props at ingest + * time — if the telemetry contract changes we want a single assertion + * point, not a per-test re-check. + */ +function assertRowShape(row: RawEventRow): void { + expect(row.schema_version, `${row.event_name}.schema_version`).to.equal(2) + expect(row.cli_version, `${row.event_name}.cli_version`).to.match(/^\d+\.\d+\.\d+/) + expect(row.os, `${row.event_name}.os`).to.be.oneOf(['darwin', 'linux', 'win32']) + expect(row.node_version, `${row.event_name}.node_version`).to.match(/^v\d+\./) + expect(row.environment, `${row.event_name}.environment`).to.be.oneOf(['development', 'production']) + expect(row.device_id, `${row.event_name}.device_id`).to.be.a('string').and.have.length.greaterThan(0) + // Anonymous emits are expected — the test never logs in, so user_id MUST be null. + expect(row.user_id, `${row.event_name}.user_id (anon)`).to.equal(null) +} + +describe('analytics lifecycle DB roundtrip e2e (M14 / M15.6)', function () { + this.timeout(120_000) + + let scenario: ScenarioEnv | undefined + const cleanupDirs: string[] = [] + + before(async function () { + if (!existsSync(BRV_BIN) || !existsSync(DIST_DAEMON)) { + console.log('[db e2e] dist missing — run `npm run build`. Skipping.') + this.skip() + } + + if (!(await checkTelemetryReachable())) { + console.log( + `[db e2e] telemetry not reachable at ${TELEMETRY_URL}.` + + ' Start it via `docker compose up -d` from byterover-telemetry. Skipping.', + ) + this.skip() + } + + if (!checkPostgresReachable()) { + console.log( + `[db e2e] postgres container '${POSTGRES_CONTAINER}' not reachable via docker exec.` + + ' Start it via `docker compose -f docker-compose.test.yml up -d postgres` from byterover-telemetry. Skipping.', + ) + this.skip() + } + + if (!checkRawEventsTableExists()) { + console.log( + '[db e2e] raw_events table missing in telemetry_test DB. Run migrations:\n' + + ' docker exec byterover-telemetry-telemetry-1 sh -c \\\n' + + " 'cd /app && node_modules/.bin/typeorm migration:run " + + "-d dist/infrastructure/persistence/typeorm/data-source.js'\n" + + 'Skipping.', + ) + this.skip() + } + }) + + beforeEach(() => { + scenario = makeScenarioEnv() + cleanupDirs.push(scenario.dataDir, scenario.home) + + expect(runBrv(['settings', 'set', 'analytics.share', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) + }) + + afterEach(async function () { + if (scenario) { + restartBrv(scenario.env) + // Preserve dirs on failure so we can inspect daemon logs + JSONL. + if (this.currentTest?.state === 'failed') { + console.log(`[db e2e] preserving dataDir=${scenario.dataDir} home=${scenario.home}`) + scenario = undefined + cleanupDirs.length = 0 + return + } + + scenario = undefined + } + + await sleep(300) + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop() + if (dir !== undefined && existsSync(dir)) { + await rm(dir, {force: true, recursive: true}) + } + } + }) + + describe('curate-tool-mode roundtrip', () => { + it('emits task_created + curate_run_completed + task_failed with promoted super-prop columns', async function () { + this.timeout(90_000) + const taskId = `e2e-db-curate-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo curate tool-mode db roundtrip', + taskId, + type: 'curate-tool-mode', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 3, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length}: ${rows.map((r) => r.event_name).join(',')})`).to.equal(true) + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.has('task_created'), 'task_created landed').to.equal(true) + expect(byName.has('curate_run_completed'), 'curate_run_completed landed').to.equal(true) + expect(byName.has('task_failed'), 'task_failed landed').to.equal(true) + + // After ENG-2925's rename landed, TaskTypeSchema only accepts the + // canonical 'curate-tool-mode' over the wire — the legacy alias + // ('curate-html-direct') is exercised by unit / integration tests + // against AnalyticsHook directly, not through the transport. + expect(byName.get('task_created')!.task_type).to.equal('curate-tool-mode') + expect(byName.get('task_failed')!.task_type).to.equal('curate-tool-mode') + expect(byName.get('curate_run_completed')!.task_type).to.equal('curate-tool-mode') + + // M15.6 failure_kind classifier — cancel always maps to 'cancelled'. + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + + // M12 curate_run_completed records the terminal outcome — cancel ⇒ 'cancelled'. + expect(byName.get('curate_run_completed')!.outcome).to.equal('cancelled') + + // Promoted super-prop columns + identity columns hold consistent values + // across all rows for this task. + for (const row of rows) assertRowShape(row) + const deviceIds = new Set(rows.map((r) => r.device_id)) + expect(deviceIds.size, 'all rows carry the same device_id').to.equal(1) + const cliVersions = new Set(rows.map((r) => r.cli_version)) + expect(cliVersions.size, 'all rows carry the same cli_version').to.equal(1) + + // duration_ms is a non-negative integer on both task_failed and curate_run_completed. + const failedProps = JSON.parse(byName.get('task_failed')!.properties_json) as {duration_ms: number} + expect(failedProps.duration_ms).to.be.a('number').and.at.least(0) + const curateProps = JSON.parse(byName.get('curate_run_completed')!.properties_json) as { + duration_ms: number + operations_added: number + operations_deleted: number + operations_failed: number + operations_merged: number + operations_updated: number + pending_review_count: number + } + expect(curateProps.duration_ms).to.be.a('number').and.at.least(0) + // Counters all 0 because we cancel before any tool calls. + expect(curateProps.operations_added, 'operations_added').to.equal(0) + expect(curateProps.operations_deleted, 'operations_deleted').to.equal(0) + expect(curateProps.operations_updated, 'operations_updated').to.equal(0) + expect(curateProps.operations_merged, 'operations_merged').to.equal(0) + expect(curateProps.operations_failed, 'operations_failed').to.equal(0) + expect(curateProps.pending_review_count, 'pending_review_count').to.equal(0) + }) + }) + + describe('query-tool-mode roundtrip', () => { + it('emits task_created + query_completed + task_failed with cancelled outcome', async function () { + this.timeout(90_000) + const taskId = `e2e-db-query-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo query tool-mode db roundtrip', + taskId, + type: 'query-tool-mode', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 3, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.has('task_created'), 'task_created landed').to.equal(true) + expect(byName.has('query_completed'), 'query_completed landed').to.equal(true) + expect(byName.has('task_failed'), 'task_failed landed').to.equal(true) + expect(byName.get('task_failed')!.task_type).to.equal('query-tool-mode') + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + expect(byName.get('query_completed')!.outcome).to.equal('cancelled') + + // query_completed payload structure: read/search counters are 0 + // because we cancel before any tool call. Cap-array fields are absent + // when empty (omit-when-empty, not zero-length array — keeps the + // payload small and forces consumers to treat missing as 'no data'). + const queryProps = JSON.parse(byName.get('query_completed')!.properties_json) as { + duration_ms: number + read_tool_call_count: number + search_call_count: number + } + expect(queryProps.duration_ms).to.be.a('number').and.at.least(0) + expect(queryProps.read_tool_call_count).to.equal(0) + expect(queryProps.search_call_count).to.equal(0) + + // task_created carries the has_files / has_folder funnel flags. + const createdProps = JSON.parse(byName.get('task_created')!.properties_json) as { + has_files: boolean + has_folder: boolean + } + expect(createdProps.has_files).to.equal(false) + expect(createdProps.has_folder).to.equal(false) + + for (const row of rows) assertRowShape(row) + }) + }) + + describe('dream-* roundtrip (no per-flavor M12 event)', () => { + for (const type of ['dream-scan', 'dream-finalize'] as const) { + it(`${type}: only task_created + task_failed land`, async function () { + this.timeout(90_000) + const taskId = `e2e-db-${type}-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: `demo ${type} db roundtrip`, + taskId, + type, + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 2, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + + const names = new Set(rows.map((r) => r.event_name)) + expect(names).to.include('task_created') + expect(names).to.include('task_failed') + // Dream task types have no per-flavor producer in AnalyticsHook. + expect(names).to.not.include('curate_run_completed') + expect(names).to.not.include('query_completed') + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.get('task_created')!.task_type).to.equal(type) + expect(byName.get('task_failed')!.task_type).to.equal(type) + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + for (const row of rows) assertRowShape(row) + }) + } + }) + + describe('search roundtrip (no per-flavor M12 event)', () => { + it('only task_created + task_failed land — search has no M12 producer', async function () { + this.timeout(90_000) + const taskId = `e2e-db-search-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo search db roundtrip', + taskId, + type: 'search', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 2, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + + const names = new Set(rows.map((r) => r.event_name)) + expect(names).to.include('task_created') + expect(names).to.include('task_failed') + expect(names).to.not.include('curate_run_completed') + expect(names).to.not.include('query_completed') + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.get('task_failed')!.task_type).to.equal('search') + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + for (const row of rows) assertRowShape(row) + }) + }) + + describe('wire-shape sanity (single scenario, deep checks)', () => { + it('client_timestamp precedes received_at and every event matches the daemon-side identity', async function () { + this.timeout(90_000) + const taskId = `e2e-db-shape-${Date.now()}` + const before = Date.now() + await fireCreateAndCancel(scenario!.env, { + content: 'demo wire-shape sanity', + taskId, + type: 'curate-tool-mode', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 3, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + const after = Date.now() + + // Ordering: client_timestamp ≤ received_at on every row, and both fall + // within the [before, after] window of this test. The 10s slack + // tolerates clock skew between the test host and the postgres container + // (typically <1s on docker-for-mac, but flush latency can stretch the + // received_at upper bound). + const beforeMs = before - 10_000 + const afterMs = after + 10_000 + for (const row of rows) { + const client = Date.parse(row.client_timestamp) + const received = Date.parse(row.received_at) + expect(Number.isFinite(client), `${row.event_name}.client_timestamp parseable`).to.equal(true) + expect(Number.isFinite(received), `${row.event_name}.received_at parseable`).to.equal(true) + expect(client, `${row.event_name}.client_timestamp >= test start`).to.be.at.least(beforeMs) + expect(received, `${row.event_name}.received_at <= test end`).to.be.at.most(afterMs) + // Allow a 5s budget for client → server transit + flush queueing. + expect(client - received, `${row.event_name}.client_timestamp - received_at ≤ 5s`).to.be.at.most(5000) + } + + // properties.device_id (per-event identity) MUST match the + // identity_device_id column (promoted from the request body). If these + // diverge it means M4.1's per-event identity stamp drifted from the + // request-header device id. + for (const row of rows) { + const props = JSON.parse(row.properties_json) as Record + expect(props.device_id, `${row.event_name}.properties.device_id`).to.equal(row.device_id) + expect(props.task_id).to.equal(taskId) + } + }) + }) + + describe('isolation: two tasks back-to-back', () => { + it('events for task A do not bleed into task B (task_id selector partitions correctly)', async function () { + this.timeout(120_000) + const taskA = `e2e-db-iso-A-${Date.now()}` + const taskB = `e2e-db-iso-B-${Date.now()}` + await fireCreateAndCancel(scenario!.env, {content: 'iso A', taskId: taskA, type: 'curate-tool-mode'}) + await fireCreateAndCancel(scenario!.env, {content: 'iso B', taskId: taskB, type: 'query-tool-mode'}) + + const ok = await waitFor( + async () => fetchEvents(taskA).length >= 3 && fetchEvents(taskB).length >= 3, + 90_000, + 2000, + ) + const rowsA = fetchEvents(taskA) + const rowsB = fetchEvents(taskB) + expect(ok, `rows A=${rowsA.length} B=${rowsB.length}`).to.equal(true) + + // No bleed: every row for A carries task_id A, every row for B carries task_id B. + for (const row of rowsA) expect(row.task_id).to.equal(taskA) + for (const row of rowsB) expect(row.task_id).to.equal(taskB) + // Task A is curate-tool-mode, B is query-tool-mode. The DB confirms + // task_type partitions cleanly along task_id boundaries. + expect(new Set(rowsA.map((r) => r.task_type))).to.deep.equal(new Set(['curate-tool-mode'])) + expect(new Set(rowsB.map((r) => r.task_type))).to.deep.equal(new Set(['query-tool-mode'])) + expect(rowsA.some((r) => r.event_name === 'curate_run_completed'), 'A has curate_run_completed').to.equal(true) + expect(rowsB.some((r) => r.event_name === 'query_completed'), 'B has query_completed').to.equal(true) + // Same device — both tasks ran under the same daemon / global config. + const devices = new Set([...rowsA.map((r) => r.device_id), ...rowsB.map((r) => r.device_id)]) + expect(devices.size, 'A + B share device_id').to.equal(1) + }) + }) +}) diff --git a/test/e2e/analytics/lifecycle-wire.e2e.ts b/test/e2e/analytics/lifecycle-wire.e2e.ts new file mode 100644 index 000000000..ae9dd616f --- /dev/null +++ b/test/e2e/analytics/lifecycle-wire.e2e.ts @@ -0,0 +1,383 @@ +/* eslint-disable no-await-in-loop */ +/** + * M14 / M15.6 end-to-end wire test — drives a real `brv` daemon with + * AnalyticsHook wired into lifecycleHooks[], dispatches real `task:create` + * + `task:cancel` events via the transport client, and asserts the + * resulting analytics rows are POSTed to a stub HTTP backend with the + * documented wire shape. + * + * Scope (different from `dev-beta.e2e.ts`): + * - dev-beta.e2e.ts emits pre-formed `cli_invocation` events via + * `analytics:track` — proves the daemon's HTTP / retry / backoff path. + * - This file fires real task-lifecycle events through TaskRouter — proves + * AnalyticsHook is in `lifecycleHooks[]` (M15.6) AND that the emit + * payload (`task_type`, `failure_kind`, alias-translated tool-mode + * names) makes it through the JSONL store + HTTP sender unchanged. + * + * Run via `npm run test:e2e:lifecycle`. Not picked up by `npm test` + * (default glob skips `test/e2e/`). Sequential by design — each `it()` + * mutates `process.env`; do NOT run mocha with `--parallel`. + */ + +import {expect} from 'chai' +import {spawnSync} from 'node:child_process' +import {existsSync, mkdtempSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {createServer as createHttpServer, type Server as HttpServer} from 'node:http' +import {type AddressInfo} from 'node:net' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {resolveLocalServerMainPath} from '../../../src/server/utils/server-main-resolver.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, '..', '..', '..') +const BRV_BIN = join(REPO_ROOT, 'bin', 'run.js') +const DIST_DAEMON = join(REPO_ROOT, 'dist', 'server', 'infra', 'daemon', 'brv-server.js') + +/** + * Real dev-beta IAM — daemon hits this once at boot for OIDC discovery + * (~400ms). Anonymous emits don't need a valid session, so analytics + * flows to our in-process stub regardless. Same default `dev-beta.e2e.ts` + * uses. M3.4's env validator rejects path components — root-only URL. + */ +const STUB_IAM = process.env.BRV_IAM_BASE_URL ?? 'https://dev-beta-iam.byterover.dev' + +type ScenarioEnv = { + dataDir: string + env: NodeJS.ProcessEnv + home: string +} + +type CapturedRequest = { + body: {events: Array<{identity: Record; name: string; properties: Record}>} + headers: Record +} + +function sleep(ms: number): Promise { + return new Promise((res) => { + setTimeout(res, ms) + }) +} + +function makeScenarioEnv(backendUrl: string): ScenarioEnv { + const dataDir = mkdtempSync(join(tmpdir(), 'brv-e2e-lifecycle-')) + const home = mkdtempSync(join(tmpdir(), 'brv-home-')) + return { + dataDir, + env: { + ...process.env, + BRV_ANALYTICS_BASE_URL: backendUrl, + BRV_DATA_DIR: dataDir, + BRV_ENV: 'development', + BRV_IAM_BASE_URL: STUB_IAM, + HOME: home, + }, + home, + } +} + +function runBrv(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 30_000): {ok: boolean; reason?: string} { + const result = spawnSync(process.execPath, [BRV_BIN, ...args], {env, stdio: 'ignore', timeout: timeoutMs}) + if (result.error) return {ok: false, reason: `brv ${args.join(' ')} failed to spawn: ${result.error.message}`} + if (result.status !== 0) return {ok: false, reason: `brv ${args.join(' ')} exit ${result.status}`} + return {ok: true} +} + +function restartBrv(env: NodeJS.ProcessEnv): void { + spawnSync(process.execPath, [BRV_BIN, 'restart'], {env, stdio: 'ignore', timeout: 30_000}) +} + +async function waitFor(predicate: () => boolean, timeoutMs: number, intervalMs = 500): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await sleep(intervalMs) + } + + return predicate() +} + +/** + * In-process HTTP backend that records every POST body to `captured`. + * Returns 200 with `{accepted: N}` matching the M4.x contract. + */ +async function startCaptureBackend(captured: CapturedRequest[]): Promise<{close: () => Promise; url: string}> { + return new Promise((res, rej) => { + const server: HttpServer = createHttpServer((req, response) => { + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + try { + const parsed = JSON.parse(body) as CapturedRequest['body'] + captured.push({body: parsed, headers: req.headers}) + response.writeHead(200, {'content-type': 'application/json'}) + response.end(JSON.stringify({accepted: parsed.events?.length ?? 0})) + } catch { + response.writeHead(400, {'content-type': 'application/json'}) + response.end('{"error":"bad-json"}') + } + }) + }) + server.on('error', rej) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo | null + if (!addr || typeof addr === 'string') { + rej(new Error('capture-backend: unexpected address shape')) + return + } + + res({ + close: () => + new Promise((closeRes) => { + server.close(() => closeRes()) + }), + url: `http://127.0.0.1:${addr.port}`, + }) + }) + }) +} + +/** + * Drive a real task lifecycle against the daemon: create → cancel. + * + * AnalyticsHook (registered as the 4th lifecycle peer in M15.6) fires + * `onTaskCreate` and `onTaskCancelled`, which emit `task_created` and + * `task_failed{failure_kind:'cancelled'}` rows respectively. The agent + * fork that would normally do the LLM step is intentionally stub'd by + * the cancel before it gets to run — we don't need a real provider for + * the wire-shape assertion. + */ +async function fireCreateAndCancel( + env: NodeJS.ProcessEnv, + task: {content: string; projectPath?: string; taskId: string; type: string}, +): Promise { + const prev = { + BRV_ANALYTICS_BASE_URL: process.env.BRV_ANALYTICS_BASE_URL, + BRV_DATA_DIR: process.env.BRV_DATA_DIR, + BRV_ENV: process.env.BRV_ENV, + BRV_IAM_BASE_URL: process.env.BRV_IAM_BASE_URL, + HOME: process.env.HOME, + } + process.env.BRV_ANALYTICS_BASE_URL = env.BRV_ANALYTICS_BASE_URL + process.env.BRV_DATA_DIR = env.BRV_DATA_DIR + process.env.BRV_ENV = env.BRV_ENV + process.env.BRV_IAM_BASE_URL = env.BRV_IAM_BASE_URL + process.env.HOME = env.HOME + try { + const {connectToDaemon} = await import('@campfirein/brv-transport-client') + const {client} = await connectToDaemon({ + clientType: 'cli', + fromDir: REPO_ROOT, + projectPath: task.projectPath ?? REPO_ROOT, + serverPath: resolveLocalServerMainPath(), + }) + + await client.requestWithAck('task:create', { + content: task.content, + projectPath: task.projectPath ?? REPO_ROOT, + taskId: task.taskId, + type: task.type, + }) + // Yield a tick so the create lifecycle hook completes before cancel. + await sleep(50) + await client.requestWithAck('task:cancel', {taskId: task.taskId}) + await client.disconnect() + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +function eventsByTaskId(captured: CapturedRequest[], taskId: string): Array<{name: string; properties: Record}> { + const out: Array<{name: string; properties: Record}> = [] + for (const req of captured) { + for (const ev of req.body.events ?? []) { + if ((ev.properties as {task_id?: string}).task_id === taskId) { + out.push({name: ev.name, properties: ev.properties}) + } + } + } + + return out +} + +describe('analytics lifecycle wire e2e (M14 / M15.6)', function () { + this.timeout(120_000) + + let backend: undefined | {close: () => Promise; url: string} + let captured: CapturedRequest[] + let scenario: ScenarioEnv | undefined + const cleanupDirs: string[] = [] + + before(function () { + if (!existsSync(BRV_BIN)) { + console.log(`[lifecycle e2e] ${BRV_BIN} missing — run \`npm install\`. Skipping suite.`) + this.skip() + } + + if (!existsSync(DIST_DAEMON)) { + console.log(`[lifecycle e2e] ${DIST_DAEMON} missing — run \`npm run build\`. Skipping suite.`) + this.skip() + } + }) + + beforeEach(async () => { + captured = [] + backend = await startCaptureBackend(captured) + scenario = makeScenarioEnv(backend.url) + cleanupDirs.push(scenario.dataDir, scenario.home) + + // Match dev-beta.e2e.ts: enable BEFORE boot. `settings set + // analytics.share true` itself starts a daemon via transport autostart, + // AND the analytics flush scheduler reads the enabled flag at boot time. + // If we boot first (with analytics disabled) then flip the flag, the + // scheduler stays dormant. + expect(runBrv(['settings', 'set', 'analytics.share', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) + }) + + afterEach(async () => { + if (scenario) { + // restart === force-flush + kill — M4.4 + M5 contract. After this + // runs, every queued row has been attempted against the stub. + restartBrv(scenario.env) + scenario = undefined + } + + if (backend) { + await backend.close() + backend = undefined + } + + await sleep(300) + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop() + if (dir !== undefined && existsSync(dir)) { + await rm(dir, {force: true, recursive: true}) + } + } + }) + + /** + * Common shape check: every event in `captured` carries the super-props + * stamped by M15.1 + base wire fields (id, timestamp, identity, name, + * properties, schema_version). + */ + function assertWireShape(captured_: CapturedRequest[]): void { + expect(captured_.length, 'at least one HTTP POST').to.be.greaterThan(0) + for (const req of captured_) { + expect(req.headers['x-byterover-device-id'], 'device-id header').to.be.a('string') + expect(req.headers['user-agent']).to.match(/^brv-cli\//) + expect(req.body.events.length, 'batch has at least one event').to.be.greaterThan(0) + for (const ev of req.body.events) { + expect(ev.name, 'event name').to.be.a('string').and.have.length.greaterThan(0) + const props = ev.properties as Record + expect(props.cli_version).to.be.a('string') + expect(props.os).to.be.a('string') + expect(props.node_version).to.be.a('string') + expect(props.environment).to.be.oneOf(['development', 'production']) + expect(props.device_id).to.be.a('string') + } + } + } + + describe('P0 — curate-tool-mode', () => { + it('cancel: task_created → task_failed{failure_kind=cancelled, task_type=curate-tool-mode}', async function () { + this.timeout(90_000) + const taskId = `e2e-curate-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo curate tool-mode', + taskId, + type: 'curate-tool-mode', + }) + + // Wait for the natural 30s flush tick to ship the JSONL rows over + // HTTP to the stub. brv restart in afterEach handles teardown + // (force-flushing what's left, killing the daemon). + const ok = await waitFor(() => eventsByTaskId(captured, taskId).length >= 2, 45_000, 1000) + expect(ok, `events for ${taskId} (saw ${eventsByTaskId(captured, taskId).length})`).to.equal(true) + + const events = eventsByTaskId(captured, taskId) + const names = events.map((e) => e.name).filter((n) => n === 'task_created' || n === 'task_failed') + expect(names).to.include.members(['task_created', 'task_failed']) + + const created = events.find((e) => e.name === 'task_created')! + const failed = events.find((e) => e.name === 'task_failed')! + // TaskTypeSchema only accepts the canonical 'curate-tool-mode' over the + // wire after ENG-2925 — the legacy 'curate-html-direct' alias path is + // exercised by the AnalyticsHook unit tests, not through the transport. + expect(created.properties.task_type).to.equal('curate-tool-mode') + expect(failed.properties.task_type).to.equal('curate-tool-mode') + // failure_kind classifier (M15.6). + expect(failed.properties.failure_kind).to.equal('cancelled') + // duration_ms is a non-negative integer. + expect(failed.properties.duration_ms).to.be.a('number').and.at.least(0) + assertWireShape(captured) + }) + }) + + describe('P0 — query-tool-mode', () => { + it('cancel: task_created → task_failed{failure_kind=cancelled, task_type=query-tool-mode}', async () => { + const taskId = `e2e-query-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo query tool-mode', + taskId, + type: 'query-tool-mode', + }) + + restartBrv(scenario!.env) + scenario = undefined + const ok = await waitFor(() => eventsByTaskId(captured, taskId).length >= 2, 30_000, 500) + expect(ok, `events for ${taskId} (saw ${eventsByTaskId(captured, taskId).length})`).to.equal(true) + + const events = eventsByTaskId(captured, taskId) + const created = events.find((e) => e.name === 'task_created')! + const failed = events.find((e) => e.name === 'task_failed')! + expect(created.properties.task_type).to.equal('query-tool-mode') + expect(failed.properties.task_type).to.equal('query-tool-mode') + expect(failed.properties.failure_kind).to.equal('cancelled') + assertWireShape(captured) + }) + }) + + describe('P1 — other task types (dream-scan / dream-finalize / search)', () => { + for (const type of ['dream-scan', 'dream-finalize', 'search'] as const) { + it(`cancel: ${type} emits only generic task_* events (no per-flavor M12)`, async () => { + const taskId = `e2e-${type}-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: `demo ${type}`, + taskId, + type, + }) + + restartBrv(scenario!.env) + scenario = undefined + const ok = await waitFor(() => eventsByTaskId(captured, taskId).length >= 2, 30_000, 500) + expect(ok, `events for ${taskId} (saw ${eventsByTaskId(captured, taskId).length})`).to.equal(true) + + const events = eventsByTaskId(captured, taskId) + const eventNames = new Set(events.map((e) => e.name)) + // M15.6 stance: dream / search task types have no M12 per-flavor + // emit. Only the generic task_created + task_failed land. + expect(eventNames).to.not.include('curate_run_completed') + expect(eventNames).to.not.include('query_completed') + expect(eventNames).to.include('task_created') + expect(eventNames).to.include('task_failed') + + const created = events.find((e) => e.name === 'task_created')! + expect(created.properties.task_type).to.equal(type) + }) + } + }) + + // Phase B (JSONL/HTTP parity, super-props deep checks) deferred until + // Phase A is green. Adding them now would conflate "harness shake-down" + // failures with "wire-shape regression" failures — keep the surface + // small while we de-flake. +}) diff --git a/test/helpers/mock-factories.ts b/test/helpers/mock-factories.ts index e7df234a0..625c4c93f 100644 --- a/test/helpers/mock-factories.ts +++ b/test/helpers/mock-factories.ts @@ -545,6 +545,7 @@ export function createMockAuthStateStore( loadToken: sandbox.stub().resolves(token), onAuthChanged: sandbox.stub(), onAuthExpired: sandbox.stub(), + onBeforeAuthChange: sandbox.stub(), startPolling: sandbox.stub(), stopPolling: sandbox.stub(), } diff --git a/test/integration/analytics-toggle.test.ts b/test/integration/analytics-toggle.test.ts new file mode 100644 index 000000000..eb82e99b6 --- /dev/null +++ b/test/integration/analytics-toggle.test.ts @@ -0,0 +1,105 @@ +import {expect} from 'chai' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {FileGlobalConfigStore} from '../../src/server/infra/storage/file-global-config-store.js' +import {GlobalConfigHandler} from '../../src/server/infra/transport/handlers/global-config-handler.js' +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, + type GlobalConfigSetAnalyticsRequest, + type GlobalConfigSetAnalyticsResponse, +} from '../../src/shared/transport/events/global-config-events.js' +import {createMockTransportServer, type MockTransportServer} from '../helpers/mock-factories.js' + +type GetHandler = (data: undefined, clientId: string) => Promise +type SetHandler = ( + data: GlobalConfigSetAnalyticsRequest, + clientId: string, +) => Promise + +describe('analytics toggle integration (handler level)', () => { + let testDir: string + let testConfigPath: string + let store: FileGlobalConfigStore + let transport: MockTransportServer + let getHandler: GetHandler + let setHandler: SetHandler + + beforeEach(() => { + testDir = join(tmpdir(), `test-analytics-toggle-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`) + testConfigPath = join(testDir, 'config.json') + + store = new FileGlobalConfigStore({ + getConfigDir: () => testDir, + getConfigPath: () => testConfigPath, + }) + transport = createMockTransportServer() + + new GlobalConfigHandler({globalConfigStore: store, transport}).setup() + + const getRaw = transport._handlers.get(GlobalConfigEvents.GET) + const setRaw = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + expect(getRaw, 'GET handler must be registered').to.exist + expect(setRaw, 'SET_ANALYTICS handler must be registered').to.exist + getHandler = getRaw as unknown as GetHandler + setHandler = setRaw as unknown as SetHandler + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, {force: true, recursive: true}) + } + }) + + describe('after enable, status reflects enabled (ticket scenario 5)', () => { + it('should observe analytics: true via GET after a successful SET_ANALYTICS true', async () => { + const setResponse = await setHandler({analytics: true}, 'client-1') + expect(setResponse.previous).to.equal(false) + expect(setResponse.current).to.equal(true) + + const getResponse = await getHandler(undefined, 'client-1') + expect(getResponse.analytics).to.equal(true) + }) + }) + + describe('after disable, status reflects disabled (ticket scenario 6)', () => { + it('should observe analytics: false via GET after enable then disable', async () => { + await setHandler({analytics: true}, 'client-1') + + const disableResponse = await setHandler({analytics: false}, 'client-1') + expect(disableResponse.previous).to.equal(true) + expect(disableResponse.current).to.equal(false) + + const getResponse = await getHandler(undefined, 'client-1') + expect(getResponse.analytics).to.equal(false) + }) + }) + + describe('concurrent SET_ANALYTICS race (ticket scenario 7)', () => { + it('should produce a coherent final state with last-writer-wins semantics under parallel writes', async () => { + const [responseA, responseB] = await Promise.all([ + setHandler({analytics: true}, 'client-A'), + setHandler({analytics: false}, 'client-B'), + ]) + + // Both calls completed without throwing. + expect(responseA.current).to.be.a('boolean') + expect(responseB.current).to.be.a('boolean') + + // Final on-disk state matches whichever request finished writing last. + // The file store is atomic per writeFile; the handler's read-mutate-write + // sequence interleaves with the event loop, so the survivor is one of + // the two requested values, never corrupted. + const finalState = await getHandler(undefined, 'client-readout') + expect([true, false]).to.include(finalState.analytics) + + // deviceId must remain a non-empty UUID (the seed step stays stable + // across the race; even if both branches generated UUIDs, the file + // ends up with exactly one of them). + expect(finalState.deviceId).to.be.a('string').and.not.be.empty + }) + }) +}) diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts new file mode 100644 index 000000000..e0122010c --- /dev/null +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -0,0 +1,207 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {AuthToken} from '../../../src/server/core/domain/entities/auth-token.js' +import type {IAuthStateReader} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {AnalyticsBatch} from '../../../src/server/core/domain/analytics/batch.js' +import {GlobalConfig} from '../../../src/server/core/domain/entities/global-config.js' +import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' +import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' +import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {NoOpAnalyticsSender} from '../../../src/server/infra/analytics/no-op-analytics-sender.js' +import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' +import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' +import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' +import {createMockTransportServer} from '../../helpers/mock-factories.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +async function waitForQueueSize(queue: BoundedQueue, expected: number, timeoutMs = 1000): Promise { + const start = Date.now() + while (queue.size() < expected) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForQueueSize: expected ${expected}, got ${queue.size()} after ${timeoutMs}ms`) + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve) + }) + } +} + +function makeAnonAuthReader(): IAuthStateReader { + const noToken: AuthToken | undefined = undefined + return {getToken: () => noToken} +} + +describe('daemon analytics tracking integration (ticket scenario 6)', () => { + let testDir: string + let testConfigPath: string + let store: FileGlobalConfigStore + + beforeEach(() => { + testDir = join(tmpdir(), `test-daemon-tracking-${Date.now()}-${randomUUID().slice(0, 8)}`) + testConfigPath = join(testDir, 'config.json') + store = new FileGlobalConfigStore({ + getConfigDir: () => testDir, + getConfigPath: () => testConfigPath, + }) + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, {force: true, recursive: true}) + } + }) + + it('should land daemon_start in the queue with full identity + super properties when analytics is enabled', async () => { + // Pre-seed the on-disk config so analytics is enabled and deviceId is stable + // for assertions. This mirrors what M1.3's `brv analytics enable` + // (now `brv settings set analytics.share true`) writes. + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + // Compose the daemon's analytics dependencies the same way feature-handlers.ts does. + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + await handler.refreshCache() + + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => handler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), + queue, + sender: new NoOpAnalyticsSender(), + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + // Fire the daemon_start sample event exactly as feature-handlers.ts does. + const before = Date.now() + client.track('daemon_start') + await waitForQueueSize(queue, 1) + const after = Date.now() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + + expect(event.name).to.equal('daemon_start') + // The wire event carries `created_at` (ISO 8601 string); the numeric + // sort key `timestamp` lives only on the stored record. `formatISO` + // drops millis, so compare against floor-to-second bounds. + expect(event.created_at).to.be.a('string') + expect(Date.parse(event.created_at)).to.be.at.least(Math.floor((before - 1000) / 1000) * 1000) + expect(Date.parse(event.created_at)).to.be.at.most(Math.floor(after / 1000) * 1000) + + // Anonymous identity: device_id only (no token in the stub reader) + expect(event.identity).to.deep.equal({device_id: validDeviceId}) + + // All five super properties stamped onto event.properties + expect(event.properties.cli_version).to.equal('3.10.3') + expect(event.properties.device_id).to.equal(validDeviceId) + expect(event.properties.environment).to.be.oneOf(['development', 'production']) + expect(event.properties.node_version).to.equal(process.version) + expect(event.properties.os).to.equal(process.platform) + }) + + it('should produce a batch that round-trips through AnalyticsBatch.fromJson', async () => { + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + await handler.refreshCache() + + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => handler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), + queue, + sender: new NoOpAnalyticsSender(), + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + client.track('daemon_start') + await waitForQueueSize(queue, 1) + + const batch = await client.flush() + const restored = AnalyticsBatch.fromJson(batch.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.schema_version).to.equal(2) + expect(restored?.events).to.have.lengthOf(1) + expect(restored?.events[0].name).to.equal('daemon_start') + expect(restored?.events[0].identity.device_id).to.equal(validDeviceId) + }) + + it('should drop daemon_start silently when analytics is disabled (default opt-in)', async () => { + // No pre-seeded config — handler.refreshCache() leaves cachedAnalytics at default false. + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + await handler.refreshCache() + + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => handler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), + queue, + sender: new NoOpAnalyticsSender(), + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + client.track('daemon_start') + // Give the event loop a few ticks; if track() were not a true no-op, + // any resolver work would land here. Two setImmediates is enough because + // the disabled path returns synchronously without scheduling anything. + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(queue.size()).to.equal(0) + const batch = await client.flush() + expect(batch.events).to.deep.equal([]) + }) + + it('should fall back to disabled (not throw) when the config store read rejects during refreshCache', async () => { + // FileGlobalConfigStore catches its own errors and never throws, but a + // hypothetical alternative implementation might. Verify refreshCache's + // catch block leaves the cache in a usable state — getCachedAnalytics + // must return false rather than throw, otherwise the daemon would crash + // on bootstrap when track() runs. + const throwingStore: IGlobalConfigStore = { + async read() { + throw new Error('read boom') + }, + async write() { + // unused in this test + }, + } + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: throwingStore, transport}) + handler.setup() + await handler.refreshCache() + + expect(() => handler.getCachedAnalytics()).to.not.throw() + expect(handler.getCachedAnalytics()).to.equal(false) + }) +}) diff --git a/test/integration/analytics/retry-cap.test.ts b/test/integration/analytics/retry-cap.test.ts new file mode 100644 index 000000000..376ad47fe --- /dev/null +++ b/test/integration/analytics/retry-cap.test.ts @@ -0,0 +1,217 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {Identity} from '../../../src/server/core/domain/analytics/identity.js' +import type {IAnalyticsSender, SendResult} from '../../../src/server/core/interfaces/analytics/i-analytics-sender.js' +import type {IIdentityResolver} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {ISuperPropertiesResolver, SuperProperties} from '../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' +import type {StoredAnalyticsRecord} from '../../../src/shared/analytics/stored-record.js' + +import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' +import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {AnalyticsEventNames} from '../../../src/shared/analytics/event-names.js' +import {MAX_ATTEMPTS} from '../../../src/shared/analytics/stored-record.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeAnonIdentity(): Identity { + return {device_id: validDeviceId} +} + +function makeSuperProps(): SuperProperties { + return { + cli_version: '3.10.3', + device_id: validDeviceId, + environment: 'production', + node_version: 'v24.13.1', + os: 'darwin', + } +} + +function makeStubIdentityResolver(identity: Identity): IIdentityResolver { + return {resolve: async () => identity} +} + +function makeStubSuperPropsResolver(props: SuperProperties): ISuperPropertiesResolver { + return {resolve: async () => props} +} + +type AllFailingSender = IAnalyticsSender & { + readonly nonEmptyCallCount: number + readonly perCallInputs: ReadonlyArray> +} + +function makeAllFailingSender(): AllFailingSender { + const perCallInputs: Array> = [] + return { + get nonEmptyCallCount() { + return perCallInputs.filter((records) => records.length > 0).length + }, + perCallInputs, + async send(records: readonly StoredAnalyticsRecord[]): Promise { + perCallInputs.push([...records]) + return {failed: records.map((r) => r.id), succeeded: []} + }, + } +} + +async function waitForRows(jsonlStore: JsonlAnalyticsStore, count: number, timeoutMs = 2000): Promise { + const start = Date.now() + while (true) { + // eslint-disable-next-line no-await-in-loop + const result = await jsonlStore.list({limit: 1000, offset: 0}) + if (result.rows.length >= count) return + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForRows: expected ${count}, got ${result.rows.length} after ${timeoutMs}ms`) + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve) + }) + } +} + +describe('M10.3 retry-cap end-to-end composition (M9.1 constant + M9.2 store + M10.2 flush)', () => { + let baseDir: string + + beforeEach(() => { + baseDir = join(tmpdir(), `analytics-retry-cap-${Date.now()}-${randomUUID().slice(0, 8)}`) + }) + + afterEach(async () => { + if (existsSync(baseDir)) { + await rm(baseDir, {force: true, recursive: true}) + } + }) + + it('should walk a row pending(0) → pending(1) → pending(2) → failed(3) over MAX_ATTEMPTS flush cycles', async () => { + expect(MAX_ATTEMPTS, 'this test is keyed off MAX_ATTEMPTS=3 from M9.1').to.equal(3) + + const jsonlStore = new JsonlAnalyticsStore({baseDir}) + const sender = makeAllFailingSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Track exactly one event so the cap walk is unambiguous. + client.track(AnalyticsEventNames.DAEMON_START) + await waitForRows(jsonlStore, 1) + + const initialRows = await jsonlStore.list({limit: 100, offset: 0}) + expect(initialRows.rows).to.have.lengthOf(1) + const targetId = initialRows.rows[0].id + expect(initialRows.rows[0].status).to.equal('pending') + expect(initialRows.rows[0].attempts).to.equal(0) + + // Flush #1: sender fails → updateStatus(failed) increments attempts to 1 but keeps status='pending'. + await client.flush() + let snap = await jsonlStore.list({limit: 100, offset: 0}) + expect(snap.rows[0].status, 'after flush #1: status stays pending').to.equal('pending') + expect(snap.rows[0].attempts, 'after flush #1: attempts=1').to.equal(1) + + // loadPending must STILL surface this row so flush #2 retries it. + let pending = await jsonlStore.loadPending() + expect(pending.map((r) => r.id), 'pending after flush #1 must include the row').to.include(targetId) + + // Flush #2: attempts=2, still pending. + await client.flush() + snap = await jsonlStore.list({limit: 100, offset: 0}) + expect(snap.rows[0].status, 'after flush #2: status stays pending').to.equal('pending') + expect(snap.rows[0].attempts, 'after flush #2: attempts=2').to.equal(2) + + pending = await jsonlStore.loadPending() + expect(pending.map((r) => r.id), 'pending after flush #2 must include the row').to.include(targetId) + + // Flush #3: attempts hits MAX_ATTEMPTS=3 → row transitions to terminal 'failed'. + await client.flush() + snap = await jsonlStore.list({limit: 100, offset: 0}) + expect(snap.rows[0].status, 'after flush #3: row transitions to terminal failed').to.equal('failed') + expect(snap.rows[0].attempts, 'after flush #3: attempts=MAX_ATTEMPTS').to.equal(MAX_ATTEMPTS) + + // loadPending now EXCLUDES the row — terminal-failed rows are not retried. + pending = await jsonlStore.loadPending() + expect(pending.map((r) => r.id), 'pending after terminal failed must NOT include the row').to.not.include(targetId) + + // The sender saw exactly MAX_ATTEMPTS non-empty inputs (once per flush cycle while pending). + expect(sender.nonEmptyCallCount, 'sender saw the row exactly MAX_ATTEMPTS times').to.equal(MAX_ATTEMPTS) + }) + + it('should leave terminal failed rows untouched on a 4th updateStatus(failed) — no overshoot', async () => { + const jsonlStore = new JsonlAnalyticsStore({baseDir}) + const sender = makeAllFailingSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await waitForRows(jsonlStore, 1) + const initial = await jsonlStore.list({limit: 100, offset: 0}) + const {id} = initial.rows[0] + + // Drive the row to terminal 'failed' (3 cycles). + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await client.flush() + } + + const beforeOvershoot = await jsonlStore.list({limit: 100, offset: 0}) + expect(beforeOvershoot.rows[0].status).to.equal('failed') + expect(beforeOvershoot.rows[0].attempts).to.equal(MAX_ATTEMPTS) + + // Direct call to updateStatus — what would happen if a stale flush retried. + await jsonlStore.updateStatus([id], 'failed') + const afterOvershoot = await jsonlStore.list({limit: 100, offset: 0}) + expect(afterOvershoot.rows[0].status, 'terminal failed stays failed').to.equal('failed') + expect(afterOvershoot.rows[0].attempts, 'attempts MUST NOT overshoot the cap').to.equal(MAX_ATTEMPTS) + }) + + it('should NOT pull a terminal-failed row back into a subsequent flush', async () => { + const jsonlStore = new JsonlAnalyticsStore({baseDir}) + const sender = makeAllFailingSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await waitForRows(jsonlStore, 1) + + // Drive to terminal failed. + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await client.flush() + } + + expect(sender.nonEmptyCallCount).to.equal(MAX_ATTEMPTS) + + // A 4th flush passes an EMPTY pending set to the sender — the row is not re-shipped. + await client.flush() + expect(sender.nonEmptyCallCount, 'flush after terminal must not re-ship the row').to.equal(MAX_ATTEMPTS) + expect(sender.perCallInputs.at(-1), '4th flush passes [] to sender').to.deep.equal([]) + + // Returned batch must be empty. + const batch = await client.flush() + expect(batch.events, 'flush over no pending rows yields empty batch').to.deep.equal([]) + }) +}) diff --git a/test/integration/analytics/transport.test.ts b/test/integration/analytics/transport.test.ts new file mode 100644 index 000000000..157cc90f6 --- /dev/null +++ b/test/integration/analytics/transport.test.ts @@ -0,0 +1,204 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {AuthToken} from '../../../src/server/core/domain/entities/auth-token.js' +import type {IAuthStateReader} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' + +import {GlobalConfig} from '../../../src/server/core/domain/entities/global-config.js' +import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' +import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' +import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {NoOpAnalyticsSender} from '../../../src/server/infra/analytics/no-op-analytics-sender.js' +import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' +import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' +import {AnalyticsHandler} from '../../../src/server/infra/transport/handlers/analytics-handler.js' +import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' +import {AnalyticsEventNames} from '../../../src/shared/analytics/event-names.js' +import {AnalyticsEvents} from '../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../helpers/mock-factories.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +type AnalyticsTrackHandler = (data: unknown, clientId: string) => Promise + +async function waitForQueueSize(queue: BoundedQueue, expected: number, timeoutMs = 1000): Promise { + const start = Date.now() + while (queue.size() < expected) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForQueueSize: expected ${expected}, got ${queue.size()} after ${timeoutMs}ms`) + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve) + }) + } +} + +function makeAnonAuthReader(): IAuthStateReader { + const noToken: AuthToken | undefined = undefined + return {getToken: () => noToken} +} + +describe('analytics:track transport round-trip integration (M2.6)', () => { + let testDir: string + let testConfigPath: string + let store: FileGlobalConfigStore + + beforeEach(() => { + testDir = join(tmpdir(), `test-analytics-transport-${Date.now()}-${randomUUID().slice(0, 8)}`) + testConfigPath = join(testDir, 'config.json') + store = new FileGlobalConfigStore({ + getConfigDir: () => testDir, + getConfigPath: () => testConfigPath, + }) + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, {force: true, recursive: true}) + } + }) + + it('should land a client-emitted event in the daemon queue with full identity + super-properties', async () => { + // Pre-seed config so analytics is enabled and deviceId is stable. + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + // Compose daemon dependencies the same way feature-handlers.ts does. + const transport = createMockTransportServer() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore: store, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + const queue = new BoundedQueue() + const analyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), + queue, + sender: new NoOpAnalyticsSender(), + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + new AnalyticsHandler({analyticsClient, transport}).setup() + + // Simulate a daemon-internal emit going through the wire `analytics:track` + // path (validated against the per-event Zod schema before dispatch). + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + expect(handler, 'analytics:track handler must be registered').to.exist + + await handler( + { + event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, + properties: { + keywords: [], + knowledge_path: 'kg/x.md', + needs_review: false, + operation_type: 'ADD', + relative_path: 'tmp/x.md', + tags: [], + task_id: 't-1', + }, + }, + 'client-1', + ) + await waitForQueueSize(queue, 1) + + const batch = await analyticsClient.flush() + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + + expect(event.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(event.identity).to.deep.equal({device_id: validDeviceId}) + + // User-supplied properties preserved end-to-end + expect(event.properties.relative_path).to.equal('tmp/x.md') + expect(event.properties.operation_type).to.equal('ADD') + + // All five super-properties stamped on receipt + expect(event.properties.cli_version).to.equal('3.10.3') + expect(event.properties.device_id).to.equal(validDeviceId) + expect(event.properties.environment).to.be.oneOf(['development', 'production']) + expect(event.properties.node_version).to.equal(process.version) + expect(event.properties.os).to.equal(process.platform) + }) + + it('should drop the event silently when analytics is disabled (default opt-in)', async () => { + const transport = createMockTransportServer() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore: store, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + const queue = new BoundedQueue() + const analyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), + queue, + sender: new NoOpAnalyticsSender(), + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: AnalyticsEventNames.DAEMON_START}, 'client-1') + // Two ticks suffice — the disabled path is sync inside track() and never schedules async work. + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(queue.size()).to.equal(0) + }) + + it('should drop a malformed payload (empty event) without enqueueing', async () => { + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + const transport = createMockTransportServer() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore: store, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + const queue = new BoundedQueue() + const analyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), + queue, + sender: new NoOpAnalyticsSender(), + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + + // Various malformed payloads + await handler({event: ''}, 'client-1') + await handler({properties: {x: 1}}, 'client-1') + await handler(null, 'client-1') + + // Drain — none should land + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(queue.size()).to.equal(0) + }) +}) diff --git a/test/integration/infra/git/isomorphic-git-service.test.ts b/test/integration/infra/git/isomorphic-git-service.test.ts index ed108a0ce..cd1162b96 100644 --- a/test/integration/infra/git/isomorphic-git-service.test.ts +++ b/test/integration/infra/git/isomorphic-git-service.test.ts @@ -33,6 +33,7 @@ function makeAuth(options?: {noAuth: true}): IAuthStateStore { loadToken: stub<[], Promise>().resolves(), onAuthChanged: stub(), onAuthExpired: stub(), + onBeforeAuthChange: stub(), startPolling: stub(), stopPolling: stub(), } diff --git a/test/integration/infra/process/analytics-hook-async-stress.test.ts b/test/integration/infra/process/analytics-hook-async-stress.test.ts new file mode 100644 index 000000000..53a851b2a --- /dev/null +++ b/test/integration/infra/process/analytics-hook-async-stress.test.ts @@ -0,0 +1,406 @@ +/** + * AnalyticsHook async stress test — drives the real `TaskRouter` over a stub + * transport with a real `AnalyticsHook` + `CurateLogHandler` to verify the + * per-task FIFO queue holds under concurrent multi-task TOOL_RESULT load. + * + * Covers the audit's Scenario F (intra-task interleaving) and a multi-task + * variant: emits MUST arrive in arrival order per-task even when underlying + * disk reads happen with microtask-scale jitter, AND terminal + * CURATE_RUN_COMPLETED MUST land AFTER every per-op emit for that task. + * + * Implementation note: the per-task queue inside AnalyticsHook serializes + * `readFrontmatterFields` calls per-task, so reads happen one-at-a-time + * for a single task. Multi-task reads can interleave across tasks. + */ + +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {relative as relativePath} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {LlmToolResultEvent} from '../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../src/server/core/domain/transport/task-info.js' +import type {IAgentPool, SubmitTaskResult} from '../../../../src/server/core/interfaces/agent/i-agent-pool.js' +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IProjectRegistry} from '../../../../src/server/core/interfaces/project/i-project-registry.js' +import type {IProjectRouter} from '../../../../src/server/core/interfaces/routing/i-project-router.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {LlmEventNames, TransportTaskEventNames} from '../../../../src/server/core/domain/transport/schemas.js' +import {AnalyticsHook} from '../../../../src/server/infra/process/analytics-hook.js' +import {CurateLogHandler} from '../../../../src/server/infra/process/curate-log-handler.js' +import {TaskRouter} from '../../../../src/server/infra/process/task-router.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' + +function makeStubTransport(sandbox: SinonSandbox): { + requestHandlers: Map + transport: ITransportServer +} { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function makeStubAgentPool(sandbox: SinonSandbox): IAgentPool { + return { + cancelQueuedTask: sandbox.stub().returns(false), + getEntries: sandbox.stub().returns([]), + getSize: sandbox.stub().returns(0), + handleAgentDisconnected: sandbox.stub(), + hasAgent: sandbox.stub().returns(false), + markIdle: sandbox.stub(), + notifyTaskCompleted: sandbox.stub(), + shutdown: sandbox.stub().resolves(), + submitTask: sandbox.stub().resolves({success: true} as SubmitTaskResult), + } +} + +function makeStubProjectRegistry(sandbox: SinonSandbox): IProjectRegistry { + const projectInfo = { + projectPath: '/proj', + registeredAt: Date.now(), + sanitizedPath: '_proj', + storagePath: '/data/proj', + } + return { + get: sandbox.stub().returns(projectInfo), + getAll: sandbox.stub().returns(new Map()), + register: sandbox.stub().returns(projectInfo), + unregister: sandbox.stub().returns(true), + } +} + +function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { + return { + addToProjectRoom: sandbox.stub(), + broadcastToProject: sandbox.stub(), + getProjectMembers: sandbox.stub().returns([]), + removeFromProjectRoom: sandbox.stub(), + } +} + +function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; trackStub: SinonStub} { + const trackStub = sandbox.stub() + const client: IAnalyticsClient = { + abort: sandbox.stub(), + flush: sandbox.stub().resolves(), + getRuntimeState: sandbox.stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sandbox.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildToolResult = (taskId: string, op: Record): LlmToolResultEvent => + ({ + callId: `call-${randomUUID()}`, + result: JSON.stringify({applied: [op]}), + sessionId: 'session-1', + taskId, + timestamp: Date.now(), + toolName: 'curate', + }) as unknown as LlmToolResultEvent + +const buildCurateTaskInfo = (taskId: string): TaskInfo => + ({ + clientId: 'client-1', + completedAt: Date.now(), + content: 'curate', + createdAt: Date.now() - 1000, + projectPath: '/proj', + status: 'completed', + taskId, + type: 'curate', + }) as unknown as TaskInfo + +const dummyFrontmatter = (tag: string): string => `---\ntags: ["${tag}"]\n---\nbody\n` + +const microtaskTick = async (count: number): Promise => { + for (let i = 0; i < count; i++) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve() + } +} + +describe('AnalyticsHook async stress (integration through TaskRouter)', () => { + let sandbox: SinonSandbox + let trackStub: SinonStub + let analyticsHook: AnalyticsHook + let curateLogHandler: CurateLogHandler + let createHandler: RequestHandler + let toolResultHandler: RequestHandler + /** Records the order in which readFile is called (for serialization assertions). */ + let readFileCallOrder: string[] + + beforeEach(() => { + sandbox = createSandbox() + const {requestHandlers, transport} = makeStubTransport(sandbox) + const agentPool = makeStubAgentPool(sandbox) + const projectRegistry = makeStubProjectRegistry(sandbox) + const projectRouter = makeStubProjectRouter(sandbox) + + readFileCallOrder = [] + // Stubbed readFile: returns a Promise that resolves AFTER a few microtasks + // so the awaited read actually yields control to the event loop. The + // microtask jitter is what makes this a "stress" test — it simulates the + // real async behaviour of `node:fs/promises.readFile` without flaky + // wall-clock timers. + const stubReadFile: (filePath: string, encoding: 'utf8') => Promise = async (filePath) => { + readFileCallOrder.push(filePath) + // Jitter: yield 3 microtasks before returning. Combined with the per-task + // queue this means reads for the same task are strictly serialized; reads + // across tasks may interleave at microtask boundaries. + await microtaskTick(3) + // Derive a stable tag from the filename for the emitted frontmatter. + const tag = filePath.replaceAll(/[^a-zA-Z0-9]+/g, '-') + return dummyFrontmatter(tag) + } + + const bundle = makeAnalyticsClient(sandbox) + trackStub = bundle.trackStub + analyticsHook = new AnalyticsHook({readFile: stubReadFile}) + analyticsHook.setAnalyticsClient(bundle.client) + // No-op store: stress test does not assert on disk log persistence. + curateLogHandler = new CurateLogHandler(() => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(true), + getById: sandbox.stub().resolves(null), + getNextId: sandbox.stub().resolves('log-1'), + list: sandbox.stub().resolves([]), + save: sandbox.stub().resolves(), + })) + + const router = new TaskRouter({ + agentPool, + getAgentForProject: () => 'agent-1', + lifecycleHooks: [curateLogHandler, analyticsHook], + projectRegistry, + projectRouter, + resolveClientProjectPath: () => '/proj', + transport, + }) + router.setup() + + const create = requestHandlers.get(TransportTaskEventNames.CREATE) + const toolResult = requestHandlers.get(LlmEventNames.TOOL_RESULT) + if (!create || !toolResult) throw new Error('expected handlers not registered') + createHandler = create + toolResultHandler = toolResult + }) + + afterEach(() => { + sandbox.restore() + }) + + async function createCurateTask(taskId: string): Promise { + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + } + + function fireToolResult(taskId: string, opSpec: {filePath: string; path: string}): Promise { + const payload = buildToolResult(taskId, { + filePath: opSpec.filePath, + needsReview: false, + path: opSpec.path, + status: 'success', + type: 'ADD', + }) + return toolResultHandler(payload as unknown, 'client-1') as Promise + } + + function getCurateOpEmits(taskId: string): Array> { + return trackStub + .getCalls() + .filter( + (c) => + c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED && + (c.args[1] as {task_id: string}).task_id === taskId, + ) + .map((c) => c.args[1] as Record) + } + + function getEmitSequenceForTask(taskId: string): string[] { + return trackStub + .getCalls() + .filter((c) => { + const props = c.args[1] as {task_id: string} + return props.task_id === taskId + }) + .map((c) => c.args[0] as string) + } + + it('serializes reads per task: 20 concurrent TOOL_RESULTs for one task call readFile in arrival order', async () => { + const taskId = 'task-A' + await createCurateTask(taskId) + + const opSpecs = Array.from({length: 20}, (_, i) => ({ + filePath: `/proj/A/op-${String(i).padStart(2, '0')}.md`, + path: `notes/A/op-${i}`, + })) + + // Fire all 20 concurrently — the routeLlmEvent handler awaits the hook + // chain, but each fire returns its own Promise and we let them race. + const promises = opSpecs.map((spec) => fireToolResult(taskId, spec)) + await Promise.all(promises) + + // readFile call order must match arrival order (proves per-task queue). + expect(readFileCallOrder, 'readFile call order = arrival order').to.deep.equal(opSpecs.map((s) => s.filePath)) + + // Emit order must match arrival order. M14 review: relative_path is + // relativized against the curate task's projectPath ('/proj'). + const emits = getCurateOpEmits(taskId) + expect(emits).to.have.lengthOf(20) + for (const [i, emit] of emits.entries()) { + expect(emit.relative_path, `emit #${i} arrival order`).to.equal(relativePath('/proj', opSpecs[i].filePath)) + } + }) + + it('preserves per-task arrival order across two tasks under interleaved fire order (30 ops total)', async () => { + await createCurateTask('task-X') + await createCurateTask('task-Y') + + const xSpecs = Array.from({length: 15}, (_, i) => ({ + filePath: `/proj/X/op-${String(i).padStart(2, '0')}.md`, + path: `notes/X/op-${i}`, + })) + const ySpecs = Array.from({length: 15}, (_, i) => ({ + filePath: `/proj/Y/op-${String(i).padStart(2, '0')}.md`, + path: `notes/Y/op-${i}`, + })) + + // Interleave fire order: X0, Y0, X1, Y1, … so cross-task scheduling + // jitter is maximised. + const promises: Array> = [] + for (let i = 0; i < 15; i++) { + promises.push(fireToolResult('task-X', xSpecs[i]), fireToolResult('task-Y', ySpecs[i])) + } + + await Promise.all(promises) + + // Per-task emit order must match per-task arrival order regardless of + // cross-task interleaving. + const xEmits = getCurateOpEmits('task-X') + const yEmits = getCurateOpEmits('task-Y') + expect(xEmits).to.have.lengthOf(15) + expect(yEmits).to.have.lengthOf(15) + for (let i = 0; i < 15; i++) { + expect(xEmits[i].relative_path, `X emit #${i}`).to.equal(relativePath('/proj', xSpecs[i].filePath)) + expect(yEmits[i].relative_path, `Y emit #${i}`).to.equal(relativePath('/proj', ySpecs[i].filePath)) + } + }) + + it('CURATE_RUN_COMPLETED lands after every per-op emit for the same task (50-op terminal stress)', async () => { + const taskId = 'task-Z' + await createCurateTask(taskId) + + const specs = Array.from({length: 50}, (_, i) => ({ + filePath: `/proj/Z/op-${String(i).padStart(2, '0')}.md`, + path: `notes/Z/op-${i}`, + })) + + // Fire all ops, but DO NOT await before firing the terminal hook — + // exercises the dispatchTerminal/onTaskCompleted "drain pendingByTask" + // path. We `await Promise.all` AFTER both event types are queued so the + // task router can interleave them. + const opPromises = specs.map((spec) => fireToolResult(taskId, spec)) + const terminalPromise = analyticsHook.onTaskCompleted(taskId, '', buildCurateTaskInfo(taskId)) + + await Promise.all([...opPromises, terminalPromise]) + + const sequence = getEmitSequenceForTask(taskId) + + // M14.3: TASK_CREATED → 50 per-op → CURATE_RUN_COMPLETED → TASK_COMPLETED. + expect( + sequence.filter((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), + 'exactly 50 per-op emits', + ).to.have.lengthOf(50) + expect( + sequence.filter((s) => s === AnalyticsEventNames.CURATE_RUN_COMPLETED), + 'exactly 1 M12 terminal emit', + ).to.have.lengthOf(1) + expect(sequence.at(-1), 'M14.3 task_completed is last in sequence').to.equal(AnalyticsEventNames.TASK_COMPLETED) + expect( + sequence.at(-2), + 'M12 curate_run_completed lands immediately before the M14.3 terminal', + ).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + + // And per-op emit order matches arrival order. + const opEmits = getCurateOpEmits(taskId) + for (let i = 0; i < 50; i++) { + expect(opEmits[i].relative_path, `op #${i} arrival order`).to.equal(relativePath('/proj', specs[i].filePath)) + } + }) + + it('three-task stress: 30 ops total (10 per task), per-task ordering and terminal sequencing all preserved', async () => { + const taskIds = ['task-P', 'task-Q', 'task-R'] as const + for (const id of taskIds) { + // eslint-disable-next-line no-await-in-loop + await createCurateTask(id) + } + + const specsByTask: Record> = { + 'task-P': Array.from({length: 10}, (_, i) => ({ + filePath: `/proj/P/op-${String(i).padStart(2, '0')}.md`, + path: `notes/P/op-${i}`, + })), + 'task-Q': Array.from({length: 10}, (_, i) => ({ + filePath: `/proj/Q/op-${String(i).padStart(2, '0')}.md`, + path: `notes/Q/op-${i}`, + })), + 'task-R': Array.from({length: 10}, (_, i) => ({ + filePath: `/proj/R/op-${String(i).padStart(2, '0')}.md`, + path: `notes/R/op-${i}`, + })), + } + + // Round-robin fire across all three tasks. + const opPromises: Array> = [] + for (let i = 0; i < 10; i++) { + for (const id of taskIds) { + opPromises.push(fireToolResult(id, specsByTask[id][i])) + } + } + + // Fire terminal for each task in parallel with op processing. + const terminalPromises = taskIds.map((id) => analyticsHook.onTaskCompleted(id, '', buildCurateTaskInfo(id))) + + await Promise.all([...opPromises, ...terminalPromises]) + + // Every task: TASK_CREATED → 10 per-op → CURATE_RUN_COMPLETED → TASK_COMPLETED. + for (const id of taskIds) { + const sequence = getEmitSequenceForTask(id) + expect(sequence, `${id} sequence length`).to.have.lengthOf(13) + expect(sequence[0], `${id}: first is task_created`).to.equal(AnalyticsEventNames.TASK_CREATED) + expect( + sequence.slice(1, 11).every((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), + `${id}: 10 per-op emits between task_created and the terminals`, + ).to.equal(true) + expect(sequence[11], `${id}: M12 run-completed precedes the M14.3 terminal`).to.equal( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + ) + expect(sequence[12], `${id}: M14.3 task_completed is last`).to.equal(AnalyticsEventNames.TASK_COMPLETED) + const opEmits = getCurateOpEmits(id) + for (let i = 0; i < 10; i++) { + expect(opEmits[i].relative_path, `${id} op #${i} arrival order`).to.equal( + relativePath('/proj', specsByTask[id][i].filePath), + ) + } + } + }) +}) diff --git a/test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts b/test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts new file mode 100644 index 000000000..38b71f168 --- /dev/null +++ b/test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts @@ -0,0 +1,255 @@ +/** + * M15.6 end-to-end wiring test — drives a real TaskRouter with AnalyticsHook + * registered as a lifecycle hook (mirroring the brv-server.ts:430 wire) and + * asserts that the generic task_created / task_completed / task_failed + * events flow through correctly. Plus failure_kind classification. + * + * Mirrors the async-stress harness but focuses on lifecycle wiring rather + * than per-op order. Stubs the transport + agent pool; the rest is real + * AnalyticsHook + TaskRouter glue. + */ + + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {TaskInfo} from '../../../../src/server/core/domain/transport/task-info.js' +import type {IAgentPool, SubmitTaskResult} from '../../../../src/server/core/interfaces/agent/i-agent-pool.js' +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IProjectRegistry} from '../../../../src/server/core/interfaces/project/i-project-registry.js' +import type {IProjectRouter} from '../../../../src/server/core/interfaces/routing/i-project-router.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {TransportTaskEventNames} from '../../../../src/server/core/domain/transport/schemas.js' +import {AnalyticsHook} from '../../../../src/server/infra/process/analytics-hook.js' +import {TaskRouter} from '../../../../src/server/infra/process/task-router.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' + +function makeStubTransport(sandbox: SinonSandbox): { + requestHandlers: Map + transport: ITransportServer +} { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function makeStubAgentPool(sandbox: SinonSandbox): IAgentPool { + return { + cancelQueuedTask: sandbox.stub().returns(false), + getEntries: sandbox.stub().returns([]), + getSize: sandbox.stub().returns(0), + handleAgentDisconnected: sandbox.stub(), + hasAgent: sandbox.stub().returns(false), + markIdle: sandbox.stub(), + notifyTaskCompleted: sandbox.stub(), + shutdown: sandbox.stub().resolves(), + submitTask: sandbox.stub().resolves({success: true} as SubmitTaskResult), + } +} + +function makeStubProjectRegistry(sandbox: SinonSandbox): IProjectRegistry { + const projectInfo = { + projectPath: '/proj', + registeredAt: Date.now(), + sanitizedPath: '_proj', + storagePath: '/data/proj', + } + return { + get: sandbox.stub().returns(projectInfo), + getAll: sandbox.stub().returns(new Map()), + register: sandbox.stub().returns(projectInfo), + unregister: sandbox.stub().returns(true), + } +} + +function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { + return { + addToProjectRoom: sandbox.stub(), + broadcastToProject: sandbox.stub(), + getProjectMembers: sandbox.stub().returns([]), + removeFromProjectRoom: sandbox.stub(), + } +} + +function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; trackStub: SinonStub} { + const trackStub = sandbox.stub() + const client: IAnalyticsClient = { + abort: sandbox.stub(), + flush: sandbox.stub().resolves(), + getRuntimeState: sandbox.stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sandbox.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildTaskInfo = (taskId: string, type: string): TaskInfo => + ({ + clientId: 'client-1', + completedAt: Date.now(), + content: 'demo', + createdAt: Date.now() - 1000, + projectPath: '/proj', + status: 'completed', + taskId, + type, + }) as unknown as TaskInfo + +describe('AnalyticsHook lifecycle wiring (M15.6 — through TaskRouter)', () => { + let sandbox: SinonSandbox + let trackStub: SinonStub + let analyticsHook: AnalyticsHook + let createHandler: RequestHandler + + beforeEach(() => { + sandbox = createSandbox() + const {requestHandlers, transport} = makeStubTransport(sandbox) + const bundle = makeAnalyticsClient(sandbox) + trackStub = bundle.trackStub + + analyticsHook = new AnalyticsHook() + analyticsHook.setAnalyticsClient(bundle.client) + + // The wire from brv-server.ts:430: AnalyticsHook is the 4th peer hook. + // The other three are intentionally omitted here so the test focuses on + // AnalyticsHook's emit surface in isolation. + const router = new TaskRouter({ + agentPool: makeStubAgentPool(sandbox), + getAgentForProject: () => 'agent-1', + lifecycleHooks: [analyticsHook], + projectRegistry: makeStubProjectRegistry(sandbox), + projectRouter: makeStubProjectRouter(sandbox), + resolveClientProjectPath: () => '/proj', + transport, + }) + router.setup() + + const create = requestHandlers.get(TransportTaskEventNames.CREATE) + if (!create) throw new Error('expected task:create handler to be registered') + createHandler = create + }) + + afterEach(() => { + sandbox.restore() + }) + + it('task_created fires immediately on task:create with the correct task_type', async () => { + await createHandler( + {content: 'curate me', projectPath: '/proj', taskId: 'task-create-fire', type: 'curate'}, + 'client-1', + ) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + expect(created, 'task_created should fire on create').to.not.equal(undefined) + const props = created?.args[1] as Record + expect(props.task_id).to.equal('task-create-fire') + expect(props.task_type).to.equal('curate') + expect(props.has_files).to.equal(false) + expect(props.has_folder).to.equal(false) + }) + + it('task_completed fires after the agent reports completion (curate task)', async () => { + const taskId = 'task-curate-success' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskCompleted(taskId, '', buildTaskInfo(taskId, 'curate')) + + const completed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_COMPLETED) + expect(completed).to.not.equal(undefined) + const props = completed?.args[1] as Record + expect(props.task_id).to.equal(taskId) + expect(props.task_type).to.equal('curate') + expect(props.duration_ms).to.be.a('number') + }) + + it('task_failed carries failure_kind="cancelled" on user cancellation', async () => { + const taskId = 'task-cancel' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskCancelled(taskId, buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect(failed).to.not.equal(undefined) + const props = failed?.args[1] as Record + expect(props.task_id).to.equal(taskId) + expect(props.task_type).to.equal('curate') + expect(props.failure_kind).to.equal('cancelled') + }) + + it('task_failed classifies a timeout error message into failure_kind="timeout"', async () => { + const taskId = 'task-timeout' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskError(taskId, 'agentic loop deadline exceeded', buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('timeout') + }) + + it('task_failed classifies an agent error message into failure_kind="agent_error"', async () => { + const taskId = 'task-agent-err' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskError(taskId, 'llm provider rejected the request', buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('agent_error') + }) + + it('task_failed defaults failure_kind="unknown" when nothing recognises the error string', async () => { + const taskId = 'task-unknown' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskError(taskId, 'kaboom', buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('unknown') + }) + + it('every tool-mode task type fires both task_created and the right terminal', async () => { + const cases = [ + {expectedTaskType: 'curate-tool-mode', taskId: 'tm-curate', type: 'curate-tool-mode'}, + {expectedTaskType: 'query-tool-mode', taskId: 'tm-query', type: 'query-tool-mode'}, + {expectedTaskType: 'dream-scan', taskId: 'tm-dream-scan', type: 'dream-scan'}, + {expectedTaskType: 'dream-finalize', taskId: 'tm-dream-finalize', type: 'dream-finalize'}, + ] as const + + for (const c of cases) { + // eslint-disable-next-line no-await-in-loop + await createHandler({content: 'demo', projectPath: '/proj', taskId: c.taskId, type: c.type}, 'client-1') + // eslint-disable-next-line no-await-in-loop + await analyticsHook.onTaskCompleted(c.taskId, '', buildTaskInfo(c.taskId, c.type)) + } + + for (const c of cases) { + const created = trackStub.getCalls().find( + (call) => + call.args[0] === AnalyticsEventNames.TASK_CREATED && + (call.args[1] as Record).task_id === c.taskId, + ) + const completed = trackStub.getCalls().find( + (call) => + call.args[0] === AnalyticsEventNames.TASK_COMPLETED && + (call.args[1] as Record).task_id === c.taskId, + ) + expect(created, `${c.taskId}: task_created`).to.not.equal(undefined) + expect(completed, `${c.taskId}: task_completed`).to.not.equal(undefined) + expect((created?.args[1] as Record).task_type).to.equal(c.expectedTaskType) + expect((completed?.args[1] as Record).task_type).to.equal(c.expectedTaskType) + } + }) +}) diff --git a/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts new file mode 100644 index 000000000..32ffc5742 --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts @@ -0,0 +1,176 @@ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type { + AuthChangedCallback, + AuthExpiredCallback, + BeforeAuthChangedCallback, + IAuthStateStore, +} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {wireAnalyticsAuthPreTransition} from '../../../../../src/server/infra/process/wire-analytics-auth-pre-transition.js' + +/** + * Integration test for the M4.4 auth pre-transition wiring. + * + * The pre-hook fires BEFORE `AuthStateStore.cachedToken` mutates, so a + * `flush()` invoked here ships pending events under the OLD session + * header. Without this ordering the events would carry old per-event + * identity but new request-level session, tripping the backend's + * identity-mismatch path. + * + * Same identity-change distinguisher as the M4.1 post-transition wiring + * (`wire-analytics-auth-transition.ts`): + * - login (anon → auth) → flush + * - logout (auth → anon) → flush + * - account switch (A → B) → flush + * - access-token refresh → SKIP (same userId) + */ + +function makeToken(overrides: Partial<{accessToken: string; userId: string}> = {}): AuthToken { + const accessToken = overrides.accessToken ?? 'access-1' + const userId = overrides.userId ?? 'user-A' + return new AuthToken({ + accessToken, + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-1', + sessionKey: 'session-1', + userEmail: 'alice@example.com', + userId, + userName: 'Alice', + }) +} + +function makeFakeAuthStateStore(initial?: AuthToken): IAuthStateStore & { + fire(oldToken?: AuthToken, newToken?: AuthToken): Promise + readonly preCallbacks: BeforeAuthChangedCallback[] +} { + const preCallbacks: BeforeAuthChangedCallback[] = [] + const cached: AuthToken | undefined = initial + return { + // Optional params let callers omit either slot for anon-side + // transitions without triggering `unicorn/no-useless-undefined` + // autofix to strip the literal `undefined` we'd otherwise pass. + async fire(oldToken?: AuthToken, newToken?: AuthToken): Promise { + // Serial execution mirrors AuthStateStore.fireBeforeAuthChange. + for (const cb of preCallbacks) { + // eslint-disable-next-line no-await-in-loop + await cb(oldToken, newToken) + } + }, + getToken: () => cached, + loadToken: async () => cached, + onAuthChanged(_cb: AuthChangedCallback): void { + // not exercised here + }, + onAuthExpired(_cb: AuthExpiredCallback): void { + // not exercised here + }, + onBeforeAuthChange(cb: BeforeAuthChangedCallback): void { + preCallbacks.push(cb) + }, + preCallbacks, + startPolling(): void { + // not exercised here + }, + stopPolling(): void { + // not exercised here + }, + } +} + +function makeFakeAnalyticsClient(): IAnalyticsClient & { + flushSpy: ReturnType +} { + const flushSpy = stub().resolves(AnalyticsBatch.create([])) + return { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: flushSpy, + flushSpy, + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track(): void { + // intentional no-op + }, + } +} + +describe('M4.4 wireAnalyticsAuthPreTransition (integration)', () => { + describe('identity change → flush', () => { + it('fires flush on login (anon → authenticated)', async () => { + const store = makeFakeAuthStateStore() // initial: undefined + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(undefined, makeToken({userId: 'user-A'})) + + expect(client.flushSpy.calledOnce, 'flush must fire on login').to.equal(true) + }) + + it('fires flush on logout (authenticated → anon)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(makeToken({userId: 'user-A'})) + + expect(client.flushSpy.calledOnce, 'flush must fire on logout').to.equal(true) + }) + + it('fires flush on account switch (userA → userB)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(makeToken({userId: 'user-A'}), makeToken({accessToken: 'access-B', userId: 'user-B'})) + + expect(client.flushSpy.calledOnce, 'flush must fire on account switch').to.equal(true) + }) + }) + + describe('token refresh → skip', () => { + it('does NOT fire flush when accessToken changes but userId is unchanged', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'a1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire( + makeToken({accessToken: 'a1', userId: 'user-A'}), + makeToken({accessToken: 'a2', userId: 'user-A'}), + ) + + expect(client.flushSpy.called, 'token refresh must NOT trigger pre-flush').to.equal(false) + }) + + it('skips a series of refreshes for the same user', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(makeToken({accessToken: 'a1', userId: 'user-A'}), makeToken({accessToken: 'a2', userId: 'user-A'})) + await store.fire(makeToken({accessToken: 'a2', userId: 'user-A'}), makeToken({accessToken: 'a3', userId: 'user-A'})) + await store.fire(makeToken({accessToken: 'a3', userId: 'user-A'}), makeToken({accessToken: 'a4', userId: 'user-A'})) + + expect(client.flushSpy.callCount).to.equal(0) + }) + }) + + describe('failure resilience', () => { + it('does NOT propagate flush() rejection (auth transition must not be blocked)', async () => { + const store = makeFakeAuthStateStore() + const client = makeFakeAnalyticsClient() + client.flushSpy.rejects(new Error('flush boom')) + wireAnalyticsAuthPreTransition(store, client) + + // If the listener propagated the error, this `await store.fire(...)` would reject. + await store.fire(undefined, makeToken({userId: 'user-A'})) + + expect(client.flushSpy.calledOnce, 'flush still attempted').to.equal(true) + }) + }) +}) diff --git a/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts new file mode 100644 index 000000000..278d22d32 --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts @@ -0,0 +1,310 @@ +import {expect} from 'chai' +import {spy, stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type { + AuthChangedCallback, + AuthExpiredCallback, + IAuthStateStore, +} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {wireAnalyticsAuthTransition} from '../../../../../src/server/infra/process/wire-analytics-auth-transition.js' + +/** + * Integration test for the M4.1 auth-transition wiring at the + * composition-root level. + * + * Three scenarios cover the regressions this wiring exists to prevent: + * + * A. Identity change (login / logout / account switch) MUST trigger + * `analyticsClient.onAuthTransition` so the queue is cleared before + * the next flush attributes prior-session events to the new user. + * + * B. Token refresh (same userId, new accessToken) MUST NOT trigger + * `onAuthTransition` — the userId-guard inside the wiring is the + * sole defense against the polling-based refresh path emitting an + * `onAuthChanged` for the same user every time the access token + * rolls. + * + * C. The wiring uses `IAuthStateStore.onAuthChanged` as a multi- + * listener registration. Earlier (pre-fix) it overwrote any + * previously-registered callback, which silently broke M4.1 in + * production because `AuthHandler.setup()` also subscribes to the + * same event. A subsequent subscriber MUST NOT cancel the analytics + * callback this wiring installed. + */ + +function makeToken(overrides: Partial<{accessToken: string; userId: string}> = {}): AuthToken { + const accessToken = overrides.accessToken ?? 'access-1' + const userId = overrides.userId ?? 'user-A' + return new AuthToken({ + accessToken, + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-1', + sessionKey: 'session-1', + userEmail: 'alice@example.com', + userId, + userName: 'Alice', + }) +} + +/** + * Stub IAuthStateStore that: + * - exposes a settable initial cached token (so `previousUserId` is + * seeded correctly when the wiring subscribes), + * - appends callbacks (multi-listener) and re-emits via `fire()` so + * tests can simulate a poll-detected change without spinning up a + * real timer + token store. + */ +function makeFakeAuthStateStore(initial?: AuthToken): IAuthStateStore & { + readonly callbacks: AuthChangedCallback[] + fire(token: AuthToken): void + fireLogout(): void +} { + const callbacks: AuthChangedCallback[] = [] + let cached: AuthToken | undefined = initial + + return { + callbacks, + fire(token: AuthToken): void { + cached = token + for (const cb of callbacks) cb(token) + }, + fireLogout(): void { + cached = undefined + for (const cb of callbacks) cb(cached) + }, + getToken: () => cached, + loadToken: async () => cached, + onAuthChanged(cb: AuthChangedCallback): void { + callbacks.push(cb) + }, + onAuthExpired(_cb: AuthExpiredCallback): void { + // not exercised here + }, + onBeforeAuthChange(): void { + // M4.4: pre-hook not exercised in this M4.1 test + }, + startPolling(): void { + // not exercised here + }, + stopPolling(): void { + // not exercised here + }, + } +} + +function makeFakeAnalyticsClient(): IAnalyticsClient & { + onAuthTransitionSpy: ReturnType +} { + const onAuthTransition = stub().resolves() + return { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition, + onAuthTransitionSpy: onAuthTransition, + // Hand-rolled noop to preserve the generic `track(event, ...rest)` + // signature — sinon's `stub()` would erase the generic and fail the + // structural-typing assignment to `IAnalyticsClient.track`. + track(): void { + // intentional no-op + }, + } +} + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) +} + +describe('M4.1 wireAnalyticsAuthTransition (integration)', () => { + describe('scenario A — identity change fires onAuthTransition', () => { + it('fires onAuthTransition when an anonymous baseline transitions to authenticated (login)', async () => { + const store = makeFakeAuthStateStore() // initial: undefined + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'onAuthTransition must fire on login').to.equal(true) + }) + + it('fires onAuthTransition when an authenticated baseline transitions to anonymous (logout)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fireLogout() + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'onAuthTransition must fire on logout').to.equal(true) + }) + + it('fires onAuthTransition when the userId changes (account switch)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fire(makeToken({accessToken: 'access-B', userId: 'user-B'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'onAuthTransition must fire on account switch').to.equal(true) + }) + }) + + describe('scenario B — token refresh (same userId) MUST NOT fire onAuthTransition', () => { + it('does NOT fire onAuthTransition when the same user refreshes the access token', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'access-1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // Polling detects an accessToken change but same userId — the + // userId-guard inside the wiring must skip the transition. + store.fire(makeToken({accessToken: 'access-2', userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.called, 'onAuthTransition must NOT fire on token refresh').to.equal(false) + }) + + it('does NOT fire onAuthTransition when a series of refreshes leaves userId unchanged', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'a1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fire(makeToken({accessToken: 'a2', userId: 'user-A'})) + store.fire(makeToken({accessToken: 'a3', userId: 'user-A'})) + store.fire(makeToken({accessToken: 'a4', userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.callCount).to.equal(0) + }) + + it('still fires onAuthTransition when an identity change interleaves with refreshes', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'a1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // refresh — skip + store.fire(makeToken({accessToken: 'a2', userId: 'user-A'})) + // logout — fire + store.fireLogout() + // login as different user — fire + store.fire(makeToken({accessToken: 'b1', userId: 'user-B'})) + // refresh as user-B — skip + store.fire(makeToken({accessToken: 'b2', userId: 'user-B'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.callCount, 'fired twice: logout + login-as-B').to.equal(2) + }) + }) + + describe('scenario C — multi-listener composition (AuthHandler regression)', () => { + it('preserves the analytics callback when a later subscriber registers', async () => { + const store = makeFakeAuthStateStore() // anonymous baseline + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // Simulate `AuthHandler.setup()` registering AFTER the analytics + // wiring — pre-fix this overwrote the analytics callback. + const broadcaster = stub() + store.onAuthChanged(broadcaster) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'analytics callback must still fire').to.equal(true) + expect(broadcaster.calledOnce, 'broadcaster callback must also fire').to.equal(true) + }) + + it('preserves the analytics callback even when multiple later subscribers register', async () => { + const store = makeFakeAuthStateStore() + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + const listener2 = stub() + const listener3 = stub() + store.onAuthChanged(listener2) + store.onAuthChanged(listener3) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce).to.equal(true) + expect(listener2.calledOnce).to.equal(true) + expect(listener3.calledOnce).to.equal(true) + }) + + it('analytics callback survives even if a later subscriber throws', async () => { + // The real AuthStateStore impl isolates throws across listeners. + // This fake mirrors that contract — if it didn't, the analytics + // callback would also break under sibling failures. + const callbacks: AuthChangedCallback[] = [] + const noToken: AuthToken | undefined = undefined + const store: IAuthStateStore & {fire(t: AuthToken): void} = { + fire(token: AuthToken): void { + for (const cb of callbacks) { + try { + cb(token) + } catch { + // isolate, like the real store does + } + } + }, + getToken: () => noToken, + loadToken: async () => noToken, + onAuthChanged(cb: AuthChangedCallback): void { + callbacks.push(cb) + }, + onAuthExpired(_cb: AuthExpiredCallback): void {}, + onBeforeAuthChange(): void { + // M4.4: pre-hook not exercised in this M4.1 test + }, + startPolling(): void {}, + stopPolling(): void {}, + } + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // A sibling subscriber registered after analytics that throws. + store.onAuthChanged(() => { + throw new Error('sibling boom') + }) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'analytics callback must still fire despite sibling throw').to.equal(true) + }) + }) + + describe('seed behavior — previousUserId is read from the cached token at subscribe time', () => { + it('does NOT fire onAuthTransition when the very first callback matches the cached userId', async () => { + // Models the production sequence: AuthStateStore.loadToken() fires + // onAuthChanged AFTER setupFeatureHandlers wired the analytics + // subscriber. If the user was already authenticated, the first + // callback delivers the SAME userId the wiring seeded from + // `getToken()` — that's a no-op, not a transition. + const initial = makeToken({accessToken: 'a1', userId: 'user-A'}) + const store = makeFakeAuthStateStore(initial) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // Same userId, different accessToken (a typical loadToken-after- + // wiring scenario when the daemon picked up an existing session). + store.fire(makeToken({accessToken: 'a2', userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.called, 'initial-cached-user must not trigger clear').to.equal(false) + }) + }) +}) diff --git a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts new file mode 100644 index 000000000..e2642a98d --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts @@ -0,0 +1,372 @@ + +import {expect} from 'chai' +import sinon from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IAnalyticsQueue} from '../../../../../src/server/core/interfaces/analytics/i-analytics-queue.js' +import type {IJsonlAnalyticsStore} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsBackoffPolicy} from '../../../../../src/server/infra/analytics/analytics-backoff-policy.js' +import {wireAnalyticsFlushScheduler} from '../../../../../src/server/infra/process/wire-analytics-flush-scheduler.js' + +/** + * Integration test for the M4.3 composition-root binding that wires + * AnalyticsClient.flush() ⇄ AnalyticsFlushScheduler. Mirrors the M4.1 / + * M4.2 wiring-helper precedent: every composition-root binding gets a + * focused integration test so a future misconfigured wiring (wrong + * isEnabled gate, missing queue ref, swapped intervals) is caught at + * unit-test speed without booting the whole daemon. + */ + +type FakeClient = IAnalyticsClient & {readonly flushCalls: number; resetFlushCalls(): void} + +function makeFakeClient(): FakeClient { + let calls = 0 + const stub: FakeClient = { + abort() { + /* M4.4: not exercised in this test */ + }, + async flush() { + calls += 1 + return AnalyticsBatch.create([]) + }, + get flushCalls() { + return calls + }, + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, + async onAuthTransition() {}, + resetFlushCalls() { + calls = 0 + }, + // Hand-rolled noop preserves the generic `track` signature. + track() { + /* no-op */ + }, + } + return stub +} + +const noop = (): void => { + /* no-op */ +} + +const asyncNoop = async (): Promise => {} + +function makeQueueStub(size: number): IAnalyticsQueue { + return { + drain: () => [], + droppedCount: () => 0, + push: noop, + size: () => size, + } +} + +/** + * Build a stub `IJsonlAnalyticsStore` whose `loadPending()` returns a + * synthetic list of `pendingCount` records. The scheduler only inspects + * `length`, so the record shapes are irrelevant — we keep them minimal + * while still matching the `StoredAnalyticsRecord` schema (camelCase + * `deviceId` only — wire-shape snake_case lives in the identity sub-DTO + * but our domain entity reflects the in-memory representation). + */ +function makeJsonlStoreStub(pendingCount: number): IJsonlAnalyticsStore { + /* eslint-disable camelcase */ + const records: StoredAnalyticsRecord[] = Array.from({length: pendingCount}, (_, i) => ({ + attempts: 0, + id: `r${String(i)}`, + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000'}, + name: 'daemon_start', + properties: {}, + status: 'pending', + timestamp: 0, + })) + /* eslint-enable camelcase */ + return { + append: asyncNoop, + clear: asyncNoop, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + list: async () => ({rows: records, total: records.length}), + loadPending: async () => records, + updateStatus: asyncNoop, + } +} + +describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('returns a scheduler that flushes via the wired client on the configured interval', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, + queue: makeQueueStub(5), + }) + + scheduler.start() + await clock.tickAsync(100) + + expect(client.flushCalls).to.equal(1) + scheduler.stop() + }) + + it('honors the isEnabled gate (disabled analytics → no flush on tick)', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => false, + jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, + queue: makeQueueStub(5), + }) + + scheduler.start() + await clock.tickAsync(500) + + expect(client.flushCalls).to.equal(0) + scheduler.stop() + }) + + it('skips interval flush when JSONL pending=0 even though queue mirror is non-zero (regression for queue-never-decrements)', async () => { + // Regression: BoundedQueue.push grows the mirror but flush only + // shrinks JSONL pending (queue.drain runs on auth transitions, not + // flushes). If the scheduler gated on queue.size() it would fire + // every 30s indefinitely after the first track ever; gating on + // pendingCount keeps the scheduler quiet once everything has shipped. + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(0), // nothing left to ship + nextIntervalMs: () => 100, + queue: makeQueueStub(50), // mirror still reflects past pushes + }) + + scheduler.start() + await clock.tickAsync(500) + + expect(client.flushCalls, 'mirror-non-zero must NOT trigger flushes when pending=0').to.equal(0) + scheduler.stop() + }) + + it('honors the queue size for the empty-skip path', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(0), + nextIntervalMs: () => 100, + queue: makeQueueStub(0), + }) + + scheduler.start() + await clock.tickAsync(500) + + expect(client.flushCalls, 'empty queue must NOT trigger a flush').to.equal(0) + scheduler.stop() + }) + + it('threshold trigger uses the wired threshold via notifyPushed()', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(20), + queue: makeQueueStub(20), + thresholdCount: 20, + }) + + scheduler.notifyPushed() + // notifyPushed defers via setImmediate; tick once to drain it. + await clock.tickAsync(1) + + expect(client.flushCalls).to.equal(1) + }) + + it('flushFinal joins an in-flight flush rather than starting a second send', async () => { + let releaseFlush!: () => void + const slowClient: IAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: () => + new Promise((resolve) => { + releaseFlush = () => resolve(AnalyticsBatch.create([])) + }), + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, + async onAuthTransition() {}, + track() { + /* no-op */ + }, + } + let flushCount = 0 + const flushSpy: IAnalyticsClient = { + ...slowClient, + async flush() { + flushCount += 1 + return slowClient.flush() + }, + } + + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: flushSpy, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, + queue: makeQueueStub(5), + }) + scheduler.start() + + await clock.tickAsync(100) + expect(flushCount).to.equal(1) + + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + releaseFlush() + await finalPromise + + expect(flushCount, 'flushFinal must join in-flight').to.equal(1) + scheduler.stop() + }) + + it('flushFinal resolves under the timeout when flush never settles', async () => { + const slowClient: IAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: () => + new Promise(() => { + /* never resolves */ + }), + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, + async onAuthTransition() {}, + track() { + /* no-op */ + }, + } + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: slowClient, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, + queue: makeQueueStub(5), + }) + + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(3000) + await finalPromise + // Reaching here proves the timeout resolved the race. + expect(true).to.equal(true) + }) + + it('uses the default 30s interval when intervalMs is omitted', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + scheduler.start() + await clock.tickAsync(29_999) + expect(client.flushCalls).to.equal(0) + await clock.tickAsync(1) + expect(client.flushCalls).to.equal(1) + scheduler.stop() + }) + + it('uses the default 20-event threshold when thresholdCount is omitted', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(19), + queue: makeQueueStub(19), + }) + + scheduler.notifyPushed() + await clock.tickAsync(1) + expect(client.flushCalls, 'below default threshold of 20 → no flush').to.equal(0) + }) + + describe('M4.5 backoff policy integration', () => { + it('reads tick delay from the wired backoffPolicy at each arm (30 → 60 → 120 → 300)', async () => { + // End-to-end timing: a real AnalyticsBackoffPolicy plus a flush + // that always reports failure (via the M4.5 reason injection) + // should produce the canonical 30s → 60s → 2m → 5m gap pattern. + // We can't easily wire a real `AnalyticsClient.runFlush` from + // this seam (the scheduler test owns the client), so we model + // production by hand-advancing the policy inside the flush body + // — same call sequence runFlush makes on a transient failure. + const policy = new AnalyticsBackoffPolicy() + const client: IAnalyticsClient = { + abort() { + /* no-op */ + }, + async flush() { + policy.onFailure() + return AnalyticsBatch.create([]) + }, + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, + async onAuthTransition() {}, + track() { + /* no-op */ + }, + } + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + backoffPolicy: policy, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + // T0: tick 1 must fire at +30s (policy starts at 30s base interval). + scheduler.start() + await clock.tickAsync(30_000) + expect(policy.consecutiveFailures(), 'after tick 1 → 1 failure').to.equal(1) + + // Tick 2 must fire at +60s from tick 1 (policy.nextDelayMs == 60_000 now). + await clock.tickAsync(59_999) + expect(policy.consecutiveFailures(), 'still 1 — 60s arm not elapsed').to.equal(1) + await clock.tickAsync(1) + expect(policy.consecutiveFailures(), 'after tick 2 → 2 failures').to.equal(2) + + // Tick 3 at +120s from tick 2. + await clock.tickAsync(120_000) + expect(policy.consecutiveFailures(), 'after tick 3 → 3 failures').to.equal(3) + + // Tick 4 at +300s from tick 3 (cap reached). + await clock.tickAsync(299_999) + expect(policy.consecutiveFailures(), 'still 3 — 5m cap not elapsed').to.equal(3) + await clock.tickAsync(1) + expect(policy.consecutiveFailures(), 'after tick 4 → 4 failures, schedule still at cap').to.equal(4) + + // One more tick at +300s confirms the cap holds. + await clock.tickAsync(300_000) + expect(policy.consecutiveFailures(), 'tick 5 at the cap').to.equal(5) + + scheduler.stop() + }) + }) +}) diff --git a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts new file mode 100644 index 000000000..ee29ec338 --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts @@ -0,0 +1,220 @@ +/* eslint-disable camelcase */ + +import {expect} from 'chai' +import nock from 'nock' +import {stub} from 'sinon' + +import type {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import type {IAuthStateReader} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {DrainingAnalyticsSender} from '../../../../../src/server/infra/analytics/draining-analytics-sender.js' +import {wireAnalyticsHttpSender} from '../../../../../src/server/infra/process/wire-analytics-http-sender.js' + +/** + * Integration test for the M4.2 composition-root binding that wires + * AnalyticsClient → IAnalyticsSender. The helper composes + * AxiosAnalyticsHttpClient + HttpAnalyticsSender; this test exercises + * the chain end-to-end through a nocked HTTP boundary so a future + * misconfigured wiring (wrong base URL, dropped header, swapped + * collaborator) is caught at unit-test speed without booting the + * whole daemon. + * + * Mirrors the M4.1 `wire-analytics-auth-transition.test.ts` precedent: + * every composition-root binding gets a focused integration test that + * locks-in the wiring shape. + */ + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' +const baseUrl = 'https://telemetry-test.byterover.dev' + +function makeConfigStore(deviceId: string = validDeviceId): IGlobalConfigStore { + const config = GlobalConfig.fromJson({analytics: true, deviceId, version: '0.0.1'}) + if (!config) throw new Error('fixture: GlobalConfig.fromJson must succeed') + return {read: stub().resolves(config), write: stub().resolves()} +} + +function makeAuthReader(token?: AuthToken): IAuthStateReader { + return {getToken: () => token} +} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: overrides.id ?? '11111111-1111-1111-1111-111111111111', + identity: {device_id: validDeviceId, user_id: 'user-123'}, + name: 'daemon_start', + properties: {cli_version: '3.12.0'}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +describe('M4.2 wireAnalyticsHttpSender (integration)', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + it('composes a sender that POSTs to /v1/events on send()', async () => { + const scope = nock(baseUrl).post('/v1/events').reply(200, {accepted: 1, rejected: 0}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: [], succeeded: ['r1']}) + expect(scope.isDone(), 'sender must POST to /v1/events').to.equal(true) + }) + + it('stamps headers from the wiring (device-id, user-agent, optional session-id)', async () => { + const token = {sessionKey: 'sess-from-wiring'} as AuthToken + const scope = nock(baseUrl) + .post('/v1/events') + .matchHeader('x-byterover-device-id', 'dev-from-config') + .matchHeader('x-byterover-session-id', 'sess-from-wiring') + .matchHeader('user-agent', 'brv-cli/3.12.0') + .matchHeader('content-type', /application\/json/) + .reply(200, {}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(token), + globalConfigStore: makeConfigStore('dev-from-config'), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord()]) + + expect(result.succeeded).to.have.lengthOf(1) + expect(scope.isDone()).to.equal(true) + }) + + it('omits session-id when no auth token is present', async () => { + let recordedHeaders: Record | undefined + const scope = nock(baseUrl) + .post('/v1/events') + .reply(function () { + recordedHeaders = this.req.headers + return [200, {}] + }) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + await sender.send([makeRecord()]) + + expect(scope.isDone()).to.equal(true) + expect(recordedHeaders, 'session header must not leak on anonymous batches').to.not.have.property('x-byterover-session-id') + }) + + it('returns failed=ids when the backend returns 5xx (sender swap surface preserved)', async () => { + // Use 500, not 503: M5.4 (ENG-2658) reclassifies a bare 503 as the nginx + // edge backstop (`rate_limited`), so a generic transient server error is + // exercised with 500. + nock(baseUrl).post('/v1/events').reply(500, {}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'a'}), makeRecord({id: 'b'})]) + + // M4.5: 5xx now propagates `reason` so the M4.5 backoff can advance. + expect(result).to.deep.equal({failed: ['a', 'b'], reason: 'http_5xx', succeeded: []}) + }) + + it('returns empty result without HTTP traffic for an empty batch', async () => { + // Strict: no nock scope registered. If the sender hits the wire, + // `nock.disableNetConnect` throws and the test fails loudly — that + // is exactly the regression we want to lock in. + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('treats missing deviceId from config as an http_4xx batch failure (no HTTP traffic)', async () => { + // Same disable-net-connect guard: a missing device id means HTTP must + // not fire. The failure is classified `http_4xx` (payload-shape) so the + // M4.5 backoff policy does not churn on the daemon-side misconfig. + const emptyStore: IGlobalConfigStore = { + read: stub().resolves(), + write: stub().resolves(), + } + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: emptyStore, + version: '3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: ['r1'], reason: 'http_4xx', succeeded: []}) + }) + + it('returns a DrainingAnalyticsSender when analyticsBaseUrl is undefined (no HTTP traffic, all ids drained)', async () => { + // Strict: no nock scope registered. With `disableNetConnect`, any + // axios construction that actually issues a request would throw. + // We additionally assert the sender's class identity to lock-in + // the wiring swap at the composition root. + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: undefined, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + expect(sender).to.be.instanceOf(DrainingAnalyticsSender) + + const result = await sender.send([makeRecord({id: 'r1'}), makeRecord({id: 'r2'})]) + expect(result).to.deep.equal({failed: [], succeeded: ['r1', 'r2']}) + }) + + it('normalises a trailing slash on the base URL (axios baseURL hygiene)', async () => { + // Without normalisation, axios's baseURL='http://x.com/' + path='/v1/events' + // emits a POST to '//v1/events' on some axios versions. The helper + // delegates normalisation to AxiosAnalyticsHttpClient; this test + // pins the contract so a refactor doesn't accidentally drop it. + const scope = nock(baseUrl).post('/v1/events').reply(200, {}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: `${baseUrl}/`, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord()]) + + expect(result.succeeded).to.have.lengthOf(1) + expect(scope.isDone()).to.equal(true) + }) +}) diff --git a/test/unit/config/environment.test.ts b/test/unit/config/environment.test.ts index 1766d7ff6..12db5ba70 100644 --- a/test/unit/config/environment.test.ts +++ b/test/unit/config/environment.test.ts @@ -197,4 +197,132 @@ describe('Environment Configuration', () => { expect(() => getCurrentConfig()).to.throw('Missing required environment variable: BRV_IAM_BASE_URL') }) }) + + describe('BRV_ANALYTICS_BASE_URL resolution (no-fallback)', () => { + let savedAnalyticsBaseUrl: string | undefined + + before(() => { + savedAnalyticsBaseUrl = process.env.BRV_ANALYTICS_BASE_URL + }) + + after(() => { + if (savedAnalyticsBaseUrl === undefined) { + delete process.env.BRV_ANALYTICS_BASE_URL + } else { + process.env.BRV_ANALYTICS_BASE_URL = savedAnalyticsBaseUrl + } + }) + + afterEach(() => { + delete process.env.BRV_ANALYTICS_BASE_URL + }) + + it('resolves to undefined when BRV_ANALYTICS_BASE_URL is unset (no code-side fallback)', async () => { + delete process.env.BRV_ANALYTICS_BASE_URL + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('resolves to undefined when BRV_ANALYTICS_BASE_URL is an empty string', async () => { + process.env.BRV_ANALYTICS_BASE_URL = '' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('resolves to undefined when BRV_ANALYTICS_BASE_URL is whitespace only', async () => { + process.env.BRV_ANALYTICS_BASE_URL = ' ' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('resolves to undefined and does not throw when BRV_ANALYTICS_BASE_URL is malformed', async () => { + process.env.BRV_ANALYTICS_BASE_URL = 'not-a-url' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + + expect(() => getCurrentConfig()).to.not.throw() + const config = getCurrentConfig() + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('preserves a valid URL and strips trailing slash', async () => { + process.env.BRV_ANALYTICS_BASE_URL = 'https://telemetry-test.example/' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal('https://telemetry-test.example') + }) + + describe('resolveAnalyticsBaseUrl (pure helper)', () => { + it('returns undefined and does not invoke the logger when input is undefined', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + const result = resolveAnalyticsBaseUrl(undefined, log) + + expect(result).to.equal(undefined) + expect(messages).to.deep.equal([]) + }) + + it('returns undefined and does not invoke the logger when input is empty / whitespace', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + expect(resolveAnalyticsBaseUrl('', log)).to.equal(undefined) + expect(resolveAnalyticsBaseUrl(' ', log)).to.equal(undefined) + expect(messages).to.deep.equal([]) + }) + + it('returns undefined AND emits one warning naming the env var and the bad value on malformed input', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + const result = resolveAnalyticsBaseUrl('not-a-url', log) + + expect(result).to.equal(undefined) + expect(messages).to.have.lengthOf(1) + expect(messages[0]).to.include('BRV_ANALYTICS_BASE_URL') + expect(messages[0]).to.include('not-a-url') + }) + + it('returns the normalized URL (trailing slash stripped) on valid input without logging', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + expect(resolveAnalyticsBaseUrl('https://valid.example/', log)).to.equal('https://valid.example') + expect(resolveAnalyticsBaseUrl('https://valid.example', log)).to.equal('https://valid.example') + expect(messages).to.deep.equal([]) + }) + }) + }) }) diff --git a/test/unit/core/domain/entities/global-config.test.ts b/test/unit/core/domain/entities/global-config.test.ts deleted file mode 100644 index dff998339..000000000 --- a/test/unit/core/domain/entities/global-config.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import {expect} from 'chai' - -import {GLOBAL_CONFIG_VERSION} from '../../../../../src/server/constants.js' -import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' - -describe('GlobalConfig', () => { - const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' - - describe('create()', () => { - it('should create a GlobalConfig with the given deviceId and current version', () => { - const config = GlobalConfig.create(validDeviceId) - - expect(config.deviceId).to.equal(validDeviceId) - expect(config.version).to.equal(GLOBAL_CONFIG_VERSION) - }) - - it('should throw an error when deviceId is empty', () => { - expect(() => GlobalConfig.create('')).to.throw('Device ID cannot be empty') - }) - - it('should throw an error when deviceId is only whitespace', () => { - expect(() => GlobalConfig.create(' ')).to.throw('Device ID cannot be empty') - }) - }) - - describe('fromJson()', () => { - it('should deserialize valid JSON', () => { - const json = { - deviceId: validDeviceId, - version: '0.0.1', - } - - const config = GlobalConfig.fromJson(json) - - expect(config).to.not.be.undefined - expect(config?.deviceId).to.equal(validDeviceId) - expect(config?.version).to.equal('0.0.1') - }) - - it('should return undefined for null', () => { - const config = GlobalConfig.fromJson(null) - - expect(config).to.be.undefined - }) - - it('should return undefined for non-object', () => { - expect(GlobalConfig.fromJson('string')).to.be.undefined - expect(GlobalConfig.fromJson(123)).to.be.undefined - expect(GlobalConfig.fromJson(true)).to.be.undefined - expect(GlobalConfig.fromJson([])).to.be.undefined - }) - - it('should return undefined when deviceId is missing', () => { - const json = {version: '0.0.1'} - - const config = GlobalConfig.fromJson(json) - - expect(config).to.be.undefined - }) - - it('should return undefined when deviceId is empty', () => { - const json = { - deviceId: '', - version: '0.0.1', - } - - const config = GlobalConfig.fromJson(json) - - expect(config).to.be.undefined - }) - - it('should return undefined when deviceId is only whitespace', () => { - const json = { - deviceId: ' ', - version: '0.0.1', - } - - const config = GlobalConfig.fromJson(json) - - expect(config).to.be.undefined - }) - - it('should return undefined when version is missing', () => { - const json = {deviceId: validDeviceId} - - const config = GlobalConfig.fromJson(json) - - expect(config).to.be.undefined - }) - - it('should return undefined when deviceId is not a string', () => { - const json = { - deviceId: 123, - version: '0.0.1', - } - - const config = GlobalConfig.fromJson(json) - - expect(config).to.be.undefined - }) - - it('should return undefined when version is not a string', () => { - const json = { - deviceId: validDeviceId, - version: 1, - } - - const config = GlobalConfig.fromJson(json) - - expect(config).to.be.undefined - }) - }) - - describe('toJson()', () => { - it('should serialize to JSON correctly', () => { - const config = GlobalConfig.create(validDeviceId) - const json = config.toJson() - - expect(json).to.deep.equal({ - deviceId: validDeviceId, - version: GLOBAL_CONFIG_VERSION, - }) - }) - - it('should roundtrip through fromJson', () => { - const original = GlobalConfig.create(validDeviceId) - const json = original.toJson() - const restored = GlobalConfig.fromJson(json) - - expect(restored).to.not.be.undefined - expect(restored?.deviceId).to.equal(original.deviceId) - expect(restored?.version).to.equal(original.version) - }) - }) - - describe('immutability', () => { - it('should have readonly properties', () => { - const config = GlobalConfig.create(validDeviceId) - - // TypeScript prevents this at compile time, but we verify the values don't change - expect(config.deviceId).to.equal(validDeviceId) - expect(config.version).to.equal(GLOBAL_CONFIG_VERSION) - }) - }) -}) diff --git a/test/unit/core/domain/entities/settings-registry.test.ts b/test/unit/core/domain/entities/settings-registry.test.ts index 449c1e895..48eddf92d 100644 --- a/test/unit/core/domain/entities/settings-registry.test.ts +++ b/test/unit/core/domain/entities/settings-registry.test.ts @@ -1,5 +1,10 @@ import {expect} from 'chai' +import type { + ReadonlyInfoSettingDescriptor, + SettingDescriptor, +} from '../../../../../src/server/core/domain/entities/settings.js' + import { findSettingDescriptor, SETTINGS_KEYS, @@ -21,6 +26,7 @@ describe('settings registry — M7 T2 shape', () => { it('declares category on every descriptor', () => { for (const descriptor of SETTINGS_REGISTRY) { expect(descriptor.category, `key ${descriptor.key} missing category`).to.be.oneOf([ + 'analytics', 'concurrency', 'llm', 'task-history', @@ -91,7 +97,11 @@ describe('settings registry — M7 T2 shape', () => { it('declares the descriptor as type=boolean with default=true', () => { const descriptor = findSettingDescriptor(SETTINGS_KEYS.UPDATE_CHECK_FOR_UPDATES) expect(descriptor?.type).to.equal('boolean') - expect(descriptor?.default).to.equal(true) + if (descriptor?.type === 'boolean') { + expect(descriptor.default).to.equal(true) + } else { + expect.fail('expected boolean descriptor for update.checkForUpdates') + } }) it('marks the descriptor as not requiring a daemon restart', () => { @@ -127,4 +137,153 @@ describe('settings registry — M7 T2 shape', () => { } }) }) + + describe('analytics category (M16.3)', () => { + it('accepts category=analytics on a readonly-info descriptor', () => { + const descriptor: ReadonlyInfoSettingDescriptor = { + category: 'analytics', + description: 'live analytics shipping snapshot', + key: '_test.analytics', + restartRequired: false, + type: 'readonly-info', + } + expect(descriptor.category).to.equal('analytics') + }) + + it('accepts category=analytics on a boolean descriptor (M16.2 will use this)', () => { + const descriptor: SettingDescriptor = { + category: 'analytics', + default: false, + description: 'analytics opt-in', + key: '_test.analytics.share', + restartRequired: false, + type: 'boolean', + } + expect(descriptor.category).to.equal('analytics') + }) + }) + + describe('readonly-info variant (M16.1)', () => { + it('accepts a readonly-info literal that narrows on type without a cast', () => { + const descriptor: ReadonlyInfoSettingDescriptor = { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } + expect(descriptor.type).to.equal('readonly-info') + }) + + it('discriminates the SettingDescriptor union on type without an `as` assertion', () => { + const descriptor: SettingDescriptor = { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } + if (descriptor.type === 'readonly-info') { + const {key} = descriptor + expect(key).to.equal('_test.snapshot') + } else { + expect.fail('expected readonly-info branch') + } + }) + + it('rejects restartRequired=true on a readonly-info descriptor at the type level', () => { + // The descriptor narrows `restartRequired` to literal `false`. The + // assignment below would fail to type-check if a future refactor + // widened the field back to `boolean`, regressing the invariant. + const descriptor: ReadonlyInfoSettingDescriptor = { + description: 'snapshot', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } + expect(descriptor.restartRequired).to.equal(false) + }) + + it('SETTINGS_REGISTRY now includes analytics.status as the first readonly-info entry (M16.3)', () => { + // M16.3 lands the first real readonly-info descriptor in the + // production registry: `analytics.status` (the live shipping + // snapshot consumed by the legacy `brv analytics status`). + const readonlyInfoEntries = SETTINGS_REGISTRY.filter((d) => d.type === 'readonly-info') + expect(readonlyInfoEntries).to.have.lengthOf(1) + expect(readonlyInfoEntries[0].key).to.equal('analytics.status') + }) + }) + + describe('analytics.share descriptor (M16.2)', () => { + it('exposes ANALYTICS_ENABLED on SETTINGS_KEYS', () => { + expect(SETTINGS_KEYS.ANALYTICS_ENABLED).to.equal('analytics.share') + }) + + it('registers a descriptor for analytics.share', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + expect(descriptor, 'descriptor must exist in SETTINGS_REGISTRY').to.exist + }) + + it('declares the descriptor as type=boolean, default=false, category=analytics, restartRequired=false', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + expect(descriptor?.type).to.equal('boolean') + if (descriptor?.type === 'boolean') { + expect(descriptor.default).to.equal(false) + } + + expect(descriptor?.category).to.equal('analytics') + expect(descriptor?.restartRequired).to.equal(false) + }) + + it('declares storage=global-config so the file store skips persistence', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + // `storage` is an optional field on writable descriptors; defaults to 'file'. + // analytics.share lives in `config.json`, not `settings.json`. + if (descriptor?.type === 'boolean') { + expect(descriptor.storage).to.equal('global-config') + } else { + expect.fail('expected boolean descriptor for analytics.share') + } + }) + + it('description fits the 80-char tooltip budget', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + expect(descriptor?.description.length).to.be.at.most(80) + }) + + it('existing writable descriptors omit the storage field (defaults to file)', () => { + const maxSize = findSettingDescriptor(SETTINGS_KEYS.AGENT_POOL_MAX_SIZE) + if (maxSize?.type === 'integer') { + // Optional field; existing descriptors do not declare it. + expect(maxSize.storage === undefined || maxSize.storage === 'file').to.equal(true) + } + }) + }) + + describe('analytics.status descriptor (M16.3)', () => { + it('exposes ANALYTICS_STATUS on SETTINGS_KEYS', () => { + expect(SETTINGS_KEYS.ANALYTICS_STATUS).to.equal('analytics.status') + }) + + it('registers a descriptor for analytics.status', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor, 'descriptor must exist in SETTINGS_REGISTRY').to.exist + }) + + it('declares the descriptor as type=readonly-info under category=analytics', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor?.type).to.equal('readonly-info') + expect(descriptor?.category).to.equal('analytics') + }) + + it('marks the descriptor as not requiring a daemon restart', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor?.restartRequired).to.equal(false) + }) + + it('description fits the 80-char tooltip budget', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor?.description.length).to.be.at.most(80) + }) + }) }) diff --git a/test/unit/core/domain/transport/schemas.test.ts b/test/unit/core/domain/transport/schemas.test.ts index 14bd34091..891fb5574 100644 --- a/test/unit/core/domain/transport/schemas.test.ts +++ b/test/unit/core/domain/transport/schemas.test.ts @@ -286,4 +286,5 @@ describe('task transport schemas', () => { } }) }) + }) diff --git a/test/unit/infra/client/client-manager-analytics.test.ts b/test/unit/infra/client/client-manager-analytics.test.ts new file mode 100644 index 000000000..df156e655 --- /dev/null +++ b/test/unit/infra/client/client-manager-analytics.test.ts @@ -0,0 +1,193 @@ + + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub, useFakeTimers} from 'sinon' + +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {ClientManager} from '../../../../src/server/infra/client/client-manager.js' +import {getClientKindFromContext} from '../../../../src/server/infra/transport/client-kind-context.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' +import {FORBIDDEN_FIELD_NAMES} from '../../../../src/shared/analytics/forbidden-field-names.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ClientManager WebUI session analytics emits', () => { + let sandbox: SinonSandbox + let manager: ClientManager + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + analyticsClient = makeFakeAnalyticsClient() + manager = new ClientManager() + manager.setAnalyticsClient(analyticsClient) + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits webui_session_started with started_at_unix_ms = client.connectedAt on webui register', () => { + const before = Date.now() + manager.register('sock-1', 'webui', '/proj/a') + const after = Date.now() + + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_STARTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {project_path_hash?: string; started_at_unix_ms: number} + expect(props.started_at_unix_ms).to.be.at.least(before) + expect(props.started_at_unix_ms).to.be.at.most(after) + expect(props.project_path_hash).to.match(/^[0-9a-f]{64}$/) + // client.connectedAt MUST equal the emitted started_at_unix_ms (join key) + expect(props.started_at_unix_ms).to.equal(manager.getClient('sock-1')!.connectedAt) + }) + + it('emits webui_session_started WITHOUT project_path_hash when no projectPath', () => { + manager.register('sock-1', 'webui') + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_STARTED) + const props = calls[0].args[1] as {project_path_hash?: string; started_at_unix_ms: number} + expect(props.project_path_hash).to.equal(undefined) + }) + + it('emits webui_session_ended with started_at_unix_ms + session_duration_ms on webui unregister', () => { + const clock = useFakeTimers(1_000_000_000) + try { + manager.register('sock-1', 'webui', '/proj/a') + const started = manager.getClient('sock-1')!.connectedAt + clock.tick(7500) + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_ENDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + project_path_hash?: string + session_duration_ms: number + started_at_unix_ms: number + } + expect(props.started_at_unix_ms).to.equal(started) + expect(props.session_duration_ms).to.equal(7500) + expect(props.project_path_hash).to.match(/^[0-9a-f]{64}$/) + } finally { + clock.restore() + } + }) + + it('does NOT emit either event for non-webui types (cli/tui/mcp/extension/agent)', () => { + const types: Array<'agent' | 'cli' | 'extension' | 'mcp' | 'tui'> = ['agent', 'cli', 'extension', 'mcp', 'tui'] + for (const [i, t] of types.entries()) { + manager.register(`sock-${i}`, t, t === 'mcp' ? undefined : `/proj/${i}`) + manager.unregister(`sock-${i}`) + } + + expect(emits(AnalyticsEventNames.WEBUI_SESSION_STARTED).length).to.equal(0) + expect(emits(AnalyticsEventNames.WEBUI_SESSION_ENDED).length).to.equal(0) + }) + + it('emit fires inside clientKindContext.run({client_kind: webui}) wrap', () => { + let observed: string | undefined + analyticsClient.trackSpy.callsFake(() => { + observed = getClientKindFromContext() + }) + manager.register('sock-1', 'webui', '/proj/a') + expect(observed).to.equal('webui') + }) + + it('reconnect: emits ended for OLD client + started for NEW client when same id re-registers as webui', () => { + const clock = useFakeTimers(1_000_000_000) + try { + manager.register('sock-1', 'webui', '/proj/a') + const firstConnectedAt = manager.getClient('sock-1')!.connectedAt + clock.tick(2000) + + manager.register('sock-1', 'webui', '/proj/b') + + const endedCalls = emits(AnalyticsEventNames.WEBUI_SESSION_ENDED) + expect(endedCalls.length).to.equal(1) + const endedProps = endedCalls[0].args[1] as {session_duration_ms: number; started_at_unix_ms: number} + expect(endedProps.started_at_unix_ms).to.equal(firstConnectedAt) + expect(endedProps.session_duration_ms).to.equal(2000) + + const startedCalls = emits(AnalyticsEventNames.WEBUI_SESSION_STARTED) + expect(startedCalls.length).to.equal(2) + } finally { + clock.restore() + } + }) + + it('is a no-op when analyticsClient is not injected', () => { + const m = new ClientManager() + m.register('sock-1', 'webui', '/proj/a') + m.unregister('sock-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + it('analytics track throwing does NOT escape register/unregister', () => { + analyticsClient.trackSpy.throws(new Error('analytics down')) + expect(() => manager.register('sock-1', 'webui', '/proj/a')).to.not.throw() + expect(() => manager.unregister('sock-1')).to.not.throw() + }) + + it('clamps session_duration_ms at 0 when clock skews backward between register and unregister', () => { + // Simulate NTP correction: register at t=1000, unregister at t=500 (earlier) + const dateNowStub = sandbox.stub(Date, 'now') + dateNowStub.onFirstCall().returns(1000) + dateNowStub.onSecondCall().returns(500) + manager.register('sock-1', 'webui', '/proj/a') + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_ENDED) + const props = calls[0].args[1] as {session_duration_ms: number} + expect(props.session_duration_ms).to.equal(0) + }) + + it('context propagates across async resolver boundary (production path simulation)', async () => { + // Simulate production flow where track() returns sync but the resolver + // reads getClientKindFromContext() AFTER an await — matching + // super-properties-resolver.ts. Verifies AsyncLocalStorage propagation + // across the microtask queue, not just sync-immediate reads. + let observed: string | undefined + const observedPromise = new Promise((resolve) => { + analyticsClient.trackSpy.callsFake(() => { + // mimic AnalyticsClient.track → trackAsync → await resolver.resolve() + Promise.resolve() + .then(async () => 0) + .then(() => { + observed = getClientKindFromContext() + resolve() + }) + .catch(() => resolve()) + }) + }) + manager.register('sock-1', 'webui', '/proj/a') + await observedPromise + expect(observed).to.equal('webui') + }) + + it('regression: neither emit payload includes any FORBIDDEN_FIELD_NAMES key', () => { + manager.register('sock-1', 'webui', '/proj/a') + manager.unregister('sock-1') + const allEmits = [ + ...emits(AnalyticsEventNames.WEBUI_SESSION_STARTED), + ...emits(AnalyticsEventNames.WEBUI_SESSION_ENDED), + ] + for (const call of allEmits) { + const props = call.args[1] as Record + for (const key of Object.keys(props)) { + expect(FORBIDDEN_FIELD_NAMES, `field ${key} must not be forbidden`).to.not.include(key) + } + } + }) +}) diff --git a/test/unit/infra/client/client-manager-mcp-analytics.test.ts b/test/unit/infra/client/client-manager-mcp-analytics.test.ts new file mode 100644 index 000000000..be7a300aa --- /dev/null +++ b/test/unit/infra/client/client-manager-mcp-analytics.test.ts @@ -0,0 +1,211 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub, useFakeTimers} from 'sinon' + +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {ClientManager} from '../../../../src/server/infra/client/client-manager.js' +import {getClientKindFromContext} from '../../../../src/server/infra/transport/client-kind-context.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' +import {FORBIDDEN_FIELD_NAMES} from '../../../../src/shared/analytics/forbidden-field-names.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ClientManager MCP session analytics emits (M15.8)', () => { + let sandbox: SinonSandbox + let manager: ClientManager + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + analyticsClient = makeFakeAnalyticsClient() + manager = new ClientManager() + manager.setAnalyticsClient(analyticsClient) + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('does NOT emit mcp_session_start on register (name unknown until handshake)', () => { + manager.register('sock-1', 'mcp') + expect(emits(AnalyticsEventNames.MCP_SESSION_START).length).to.equal(0) + }) + + it('emits mcp_session_start when setAgentName lands for an MCP client', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + + const calls = emits(AnalyticsEventNames.MCP_SESSION_START) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {client_name: string} + expect(props.client_name).to.equal('Cursor') + }) + + it('does NOT re-emit mcp_session_start on duplicate setAgentName (idempotent)', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + manager.setAgentName('sock-1', 'Cursor') + expect(emits(AnalyticsEventNames.MCP_SESSION_START).length).to.equal(1) + }) + + it('does NOT emit on setAgentName for non-MCP types (cli/tui/extension/webui/agent)', () => { + const types: Array<'agent' | 'cli' | 'extension' | 'tui' | 'webui'> = [ + 'agent', + 'cli', + 'extension', + 'tui', + 'webui', + ] + for (const [i, t] of types.entries()) { + const id = `sock-${i}` + manager.register(id, t, t === 'webui' ? '/proj' : undefined) + manager.setAgentName(id, 'WhateverName') + } + + expect(emits(AnalyticsEventNames.MCP_SESSION_START).length).to.equal(0) + }) + + it('emits mcp_session_ended on unregister when agentName was set', () => { + const clock = useFakeTimers(1_700_000_000_000) + try { + manager.register('sock-1', 'mcp') + const started = manager.getClient('sock-1')!.connectedAt + manager.setAgentName('sock-1', 'Cursor') + clock.tick(8500) + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + client_name: string + session_duration_ms: number + started_at_unix_ms: number + } + expect(props.client_name).to.equal('Cursor') + expect(props.started_at_unix_ms).to.equal(started) + expect(props.session_duration_ms).to.equal(8500) + } finally { + clock.restore() + } + }) + + it('does NOT emit mcp_session_ended on unregister when agentName was never set', () => { + manager.register('sock-1', 'mcp') + manager.unregister('sock-1') + expect(emits(AnalyticsEventNames.MCP_SESSION_ENDED).length).to.equal(0) + }) + + it('reconnect: emits ended for old MCP session + clears state for new register cycle', () => { + const clock = useFakeTimers(1_700_000_000_000) + try { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + const firstConnectedAt = manager.getClient('sock-1')!.connectedAt + clock.tick(2000) + + // Reconnect: same id, fresh ClientInfo, fresh handshake. + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + + const endedCalls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + expect(endedCalls.length).to.equal(1) + const endedProps = endedCalls[0].args[1] as { + client_name: string + session_duration_ms: number + started_at_unix_ms: number + } + expect(endedProps.client_name).to.equal('Cursor') + expect(endedProps.started_at_unix_ms).to.equal(firstConnectedAt) + expect(endedProps.session_duration_ms).to.equal(2000) + + const startedCalls = emits(AnalyticsEventNames.MCP_SESSION_START) + expect(startedCalls.length).to.equal(2) + } finally { + clock.restore() + } + }) + + it('end-event carries the SAME client_name that start emitted, even if agentName were re-mutated mid-session', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') // emits start with 'Cursor' + manager.setAgentName('sock-1', 'Claude Code') // wasFirstMcpHandshake=false; mutates _agentName but does NOT re-emit start + manager.unregister('sock-1') + + const startedCalls = emits(AnalyticsEventNames.MCP_SESSION_START) + expect(startedCalls.length).to.equal(1) + expect((startedCalls[0].args[1] as {client_name: string}).client_name).to.equal('Cursor') + + const endedCalls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + expect(endedCalls.length).to.equal(1) + // CRITICAL: the end-event must carry 'Cursor' (the name emitted at start), + // NOT the post-mutation 'Claude Code' value. Otherwise backend correlation + // of start↔end via client_name silently breaks. + expect((endedCalls[0].args[1] as {client_name: string}).client_name).to.equal('Cursor') + }) + + it('clamps session_duration_ms at 0 when clock skews backward between register and unregister', () => { + const dateNowStub = sandbox.stub(Date, 'now') + dateNowStub.onFirstCall().returns(1000) // register + dateNowStub.onSecondCall().returns(500) // unregister + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + const props = calls[0].args[1] as {session_duration_ms: number} + expect(props.session_duration_ms).to.equal(0) + }) + + it('emit fires inside clientKindContext.run({client_kind: mcp}) wrap', () => { + let observed: string | undefined + analyticsClient.trackSpy.callsFake(() => { + observed = getClientKindFromContext() + }) + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + expect(observed).to.equal('mcp') + }) + + it('is a no-op when analyticsClient is not injected', () => { + const m = new ClientManager() + m.register('sock-1', 'mcp') + m.setAgentName('sock-1', 'Cursor') + m.unregister('sock-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + it('analytics track throwing does NOT escape setAgentName/unregister', () => { + analyticsClient.trackSpy.throws(new Error('analytics down')) + manager.register('sock-1', 'mcp') + expect(() => manager.setAgentName('sock-1', 'Cursor')).to.not.throw() + expect(() => manager.unregister('sock-1')).to.not.throw() + }) + + it('regression: neither emit payload includes any FORBIDDEN_FIELD_NAMES key', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + manager.unregister('sock-1') + const allEmits = [ + ...emits(AnalyticsEventNames.MCP_SESSION_START), + ...emits(AnalyticsEventNames.MCP_SESSION_ENDED), + ] + for (const call of allEmits) { + const props = call.args[1] as Record + for (const key of Object.keys(props)) { + expect(FORBIDDEN_FIELD_NAMES, `field ${key} must not be forbidden`).to.not.include(key) + } + } + }) +}) diff --git a/test/unit/infra/daemon/shutdown-handler.test.ts b/test/unit/infra/daemon/shutdown-handler.test.ts index bf748820d..07d4104f0 100644 --- a/test/unit/infra/daemon/shutdown-handler.test.ts +++ b/test/unit/infra/daemon/shutdown-handler.test.ts @@ -275,4 +275,71 @@ describe('shutdown-handler', () => { expect(messages.some((m: string) => m.includes('Shutdown initiated'))).to.be.true expect(messages.some((m: string) => m.includes('Shutdown complete'))).to.be.true }) + + describe('M4.3 analyticsFinalFlush hook', () => { + it('invokes analyticsFinalFlush after agent pool stops and before transport stops', async () => { + const analyticsFlushStub = sandbox.stub().callsFake(async () => { + callOrder.push('analyticsFinalFlush') + }) + + const handler = new ShutdownHandler({ + analyticsFinalFlush: analyticsFlushStub, + daemonResilience: mockDaemonResilience, + heartbeatWriter: mockHeartbeatWriter, + idleTimeoutPolicy: mockIdleTimeoutPolicy, + instanceManager: mockInstanceManager, + log: logStub, + transportServer: mockTransportServer, + }) + + await handler.shutdown() + + expect(analyticsFlushStub.calledOnce).to.equal(true) + // Final flush sits between heartbeat.stop and transport.stop in this + // config (no agent pool wired, so step 5 is a no-op). + const heartbeatIdx = callOrder.indexOf('heartbeatWriter.stop') + const flushIdx = callOrder.indexOf('analyticsFinalFlush') + const transportIdx = callOrder.indexOf('transportServer.stop') + expect(flushIdx).to.be.greaterThan(heartbeatIdx) + expect(flushIdx).to.be.lessThan(transportIdx) + }) + + it('continues shutdown when analyticsFinalFlush rejects', async () => { + const analyticsFlushStub = sandbox.stub().rejects(new Error('telemetry down')) + + const handler = new ShutdownHandler({ + analyticsFinalFlush: analyticsFlushStub, + daemonResilience: mockDaemonResilience, + heartbeatWriter: mockHeartbeatWriter, + idleTimeoutPolicy: mockIdleTimeoutPolicy, + instanceManager: mockInstanceManager, + log: logStub, + transportServer: mockTransportServer, + }) + + await handler.shutdown() + + // Subsequent steps still run despite the analytics rejection. + expect(transportStopStub.calledOnce).to.equal(true) + expect(instanceReleaseStub.calledOnce).to.equal(true) + const messages = logStub.getCalls().map((c) => c.args[0]) + expect(messages.some((m: string) => m.includes('Error during final analytics flush'))).to.equal(true) + }) + + it('skips the hook when analyticsFinalFlush is not wired', async () => { + const handler = new ShutdownHandler({ + daemonResilience: mockDaemonResilience, + heartbeatWriter: mockHeartbeatWriter, + idleTimeoutPolicy: mockIdleTimeoutPolicy, + instanceManager: mockInstanceManager, + log: logStub, + transportServer: mockTransportServer, + }) + + await handler.shutdown() + + // No flush traces in the call order, just the pre-existing steps. + expect(callOrder).to.not.include('analyticsFinalFlush') + }) + }) }) diff --git a/test/unit/infra/process/task-router-client-identity-snapshot.test.ts b/test/unit/infra/process/task-router-client-identity-snapshot.test.ts new file mode 100644 index 000000000..cd93634cd --- /dev/null +++ b/test/unit/infra/process/task-router-client-identity-snapshot.test.ts @@ -0,0 +1,199 @@ +/** + * M15.8 — TaskRouter snapshots the submitting client's identity + * (clientType + clientName) onto TaskInfo at task-create time so + * AnalyticsHook can emit mcp_tool_called even if the MCP client + * disconnects mid-task. + */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAgentPool, SubmitTaskResult} from '../../../../src/server/core/interfaces/agent/i-agent-pool.js' +import type {IProjectRegistry} from '../../../../src/server/core/interfaces/project/i-project-registry.js' +import type {IProjectRouter} from '../../../../src/server/core/interfaces/routing/i-project-router.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {TransportTaskEventNames} from '../../../../src/server/core/domain/transport/schemas.js' +import {TaskRouter} from '../../../../src/server/infra/process/task-router.js' + +function makeProjectInfo(projectPath: string) { + return { + projectPath, + registeredAt: Date.now(), + sanitizedPath: projectPath.replaceAll('/', '_'), + storagePath: `/data${projectPath}`, + } +} + +function makeStubTransportServer(sandbox: SinonSandbox) { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function makeStubAgentPool(sandbox: SinonSandbox): IAgentPool & {submitTask: SinonStub} { + return { + cancelQueuedTask: sandbox.stub().returns(false), + getEntries: sandbox.stub().returns([]), + getSize: sandbox.stub().returns(0), + handleAgentDisconnected: sandbox.stub(), + hasAgent: sandbox.stub().returns(false), + markIdle: sandbox.stub(), + notifyTaskCompleted: sandbox.stub(), + shutdown: sandbox.stub().resolves(), + submitTask: sandbox.stub().resolves({success: true} as SubmitTaskResult), + } +} + +function makeStubProjectRegistry(sandbox: SinonSandbox): IProjectRegistry { + return { + get: sandbox.stub().callsFake((path: string) => makeProjectInfo(path)), + getAll: sandbox.stub().returns(new Map()), + register: sandbox.stub().callsFake((path: string) => makeProjectInfo(path)), + unregister: sandbox.stub().returns(true), + } +} + +function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { + return { + addToProjectRoom: sandbox.stub(), + broadcastToProject: sandbox.stub(), + getProjectMembers: sandbox.stub().returns([]), + removeFromProjectRoom: sandbox.stub(), + } +} + +const makeRequest = (overrides: Record = {}) => ({ + content: 'do thing', + projectPath: '/proj', + taskId: randomUUID(), + type: 'query-tool-mode' as const, + ...overrides, +}) + +describe('TaskRouter handleTaskCreate client-identity snapshot (M15.8)', () => { + let sandbox: SinonSandbox + let transportHelper: ReturnType + let agentPool: ReturnType + let projectRegistry: ReturnType + let projectRouter: ReturnType + let getAgentForProject: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + transportHelper = makeStubTransportServer(sandbox) + agentPool = makeStubAgentPool(sandbox) + projectRegistry = makeStubProjectRegistry(sandbox) + projectRouter = makeStubProjectRouter(sandbox) + getAgentForProject = sandbox.stub().returns('agent-1') + }) + + afterEach(() => sandbox.restore()) + + it('stamps clientType + clientName from resolveClientIdentity onto the stored TaskInfo', async () => { + const resolveClientIdentity = sandbox.stub().returns({clientName: 'Cursor', clientType: 'mcp'}) + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + resolveClientIdentity, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + expect(handler).to.exist + const request = makeRequest() + await handler!(request, 'sock-1') + + expect(resolveClientIdentity.calledWith('sock-1')).to.equal(true) + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored, 'task should be stored after handleTaskCreate').to.exist + expect(stored!.clientType).to.equal('mcp') + expect(stored!.clientName).to.equal('Cursor') + }) + + it('omits clientName when the resolver returns only clientType', async () => { + const resolveClientIdentity = sandbox.stub().returns({clientType: 'cli'}) + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + resolveClientIdentity, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeRequest() + await handler!(request, 'sock-cli') + + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored).to.exist + expect(stored!.clientType).to.equal('cli') + expect(stored!.clientName).to.equal(undefined) + }) + + it('leaves both fields undefined when no resolver is configured', async () => { + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeRequest() + await handler!(request, 'sock-x') + + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored).to.exist + expect(stored!.clientType).to.equal(undefined) + expect(stored!.clientName).to.equal(undefined) + }) + + it('leaves both fields undefined when resolver returns undefined (unknown client)', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const resolveClientIdentity = sandbox.stub().returns(undefined) + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + resolveClientIdentity, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeRequest() + await handler!(request, 'sock-y') + + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored).to.exist + expect(stored!.clientType).to.equal(undefined) + expect(stored!.clientName).to.equal(undefined) + }) +}) diff --git a/test/unit/infra/state/auth-state-store.test.ts b/test/unit/infra/state/auth-state-store.test.ts index 58b417a48..b597910a2 100644 --- a/test/unit/infra/state/auth-state-store.test.ts +++ b/test/unit/infra/state/auth-state-store.test.ts @@ -407,4 +407,256 @@ describe('AuthStateStore', () => { expect(store.getToken()).to.equal(expiredToken) }) }) + + describe('multiple onAuthChanged listeners (M4.1 regression)', () => { + it('should fire EVERY registered onAuthChanged callback when token appears', async () => { + const cb1 = sandbox.stub() + const cb2 = sandbox.stub() + const cb3 = sandbox.stub() + store.onAuthChanged(cb1) + store.onAuthChanged(cb2) + store.onAuthChanged(cb3) + + const token = createValidToken() + loadStub.resolves(token) + await store.loadToken() + + expect(cb1.calledOnce, 'cb1 should fire').to.be.true + expect(cb2.calledOnce, 'cb2 should fire').to.be.true + expect(cb3.calledOnce, 'cb3 should fire').to.be.true + expect(cb1.calledWith(token)).to.be.true + expect(cb2.calledWith(token)).to.be.true + expect(cb3.calledWith(token)).to.be.true + }) + + it('should fire listeners in registration order', async () => { + const order: number[] = [] + store.onAuthChanged(() => order.push(1)) + store.onAuthChanged(() => order.push(2)) + store.onAuthChanged(() => order.push(3)) + + loadStub.resolves(createValidToken()) + await store.loadToken() + + expect(order).to.deep.equal([1, 2, 3]) + }) + + it('should keep firing later listeners even if an earlier one throws', async () => { + const cb1 = sandbox.stub().throws(new Error('listener boom')) + const cb2 = sandbox.stub() + const cb3 = sandbox.stub() + store.onAuthChanged(cb1) + store.onAuthChanged(cb2) + store.onAuthChanged(cb3) + + loadStub.resolves(createValidToken()) + + // The polling loop must not propagate the listener throw — would + // otherwise crash the daemon's auth poll cycle. + await store.loadToken() + + expect(cb1.calledOnce).to.be.true + expect(cb2.calledOnce, 'cb2 must still fire after cb1 threw').to.be.true + expect(cb3.calledOnce, 'cb3 must still fire after cb1 threw').to.be.true + }) + + it('should fire EVERY onAuthExpired callback', async () => { + const cb1 = sandbox.stub() + const cb2 = sandbox.stub() + store.onAuthExpired(cb1) + store.onAuthExpired(cb2) + + const validToken = createValidToken({accessToken: 'shared'}) + loadStub.resolves(validToken) + await store.loadToken() + + const expiredToken = createExpiredToken({accessToken: 'shared'}) + loadStub.resolves(expiredToken) + store.startPolling() + await clock.tickAsync(POLL_INTERVAL) + + expect(cb1.calledOnce).to.be.true + expect(cb2.calledOnce).to.be.true + }) + }) + + describe('onBeforeAuthChange (M4.4 pre-transition hook)', () => { + // The pre-hook fires BEFORE `cachedToken` is mutated, so listeners + // (analytics force-flush) can read `getToken()` and observe the + // OLD token. Without this ordering guarantee, M4.4's flush-then-drop + // hybrid would ship events with the NEW session header but OLD + // per-event identity — backend would treat them as anonymous. + const HANG_GUARD_MS = 50 // shrunk for tests; prod default is 6000 + + it('fires the pre-listener BEFORE cachedToken mutates (getToken returns OLD)', async () => { + const token1 = createValidToken({accessToken: 'old'}) + loadStub.resolves(token1) + await store.loadToken() + + let observedDuringPre: string | undefined + store.onBeforeAuthChange(async (_oldToken, _newToken) => { + // Reading getToken() here MUST return the OLD token — the whole + // point of the pre-hook is the OLD token is still in place. + observedDuringPre = store.getToken()?.accessToken + }) + + const token2 = createValidToken({accessToken: 'new'}) + loadStub.resolves(token2) + await store.loadToken() + + expect(observedDuringPre, 'pre-listener must see OLD token via getToken()').to.equal('old') + expect(store.getToken()?.accessToken, 'post-transition cached token is NEW').to.equal('new') + }) + + it('awaits the async pre-listener before firing onAuthChanged (post-hook)', async () => { + const order: string[] = [] + let releasePre!: () => void + store.onBeforeAuthChange( + () => + new Promise((resolve) => { + order.push('pre-start') + releasePre = () => { + order.push('pre-end') + resolve() + } + }), + ) + store.onAuthChanged(() => { + order.push('post') + }) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + + const loadPromise = store.loadToken() + // Pre-listener registered but not resolved yet → post must NOT fire + await clock.tickAsync(0) + expect(order).to.deep.equal(['pre-start']) + + releasePre() + await loadPromise + + expect(order).to.deep.equal(['pre-start', 'pre-end', 'post']) + }) + + it('skips pre-listeners when accessToken is unchanged (token-refresh shortcut path is unrelated)', async () => { + // Same accessToken across loads = no change detected, NO pre-listener fire. + const preCb = sandbox.stub().resolves() + store.onBeforeAuthChange(preCb) + + const token = createValidToken({accessToken: 'stable'}) + loadStub.resolves(token) + await store.loadToken() // first load: undefined → token, pre fires + await store.loadToken() // second load: same accessToken, NO pre + + expect(preCb.calledOnce, 'pre fires only on the actual transition').to.be.true + }) + + it('hang-guard: pre-listener that never resolves does NOT block the transition past beforeAuthChangeTimeoutMs', async () => { + // Construct a store with a small hang-guard so the test can finish + // in reasonable time. Prod default is 6s. + const fastStore = new AuthStateStore({ + beforeAuthChangeTimeoutMs: HANG_GUARD_MS, + pollIntervalMs: POLL_INTERVAL, + tokenStore, + }) + fastStore.onBeforeAuthChange( + () => + new Promise(() => { + /* never resolves */ + }), + ) + const postCb = sandbox.stub() + fastStore.onAuthChanged(postCb) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + const loadPromise = fastStore.loadToken() + + await clock.tickAsync(HANG_GUARD_MS + 1) + await loadPromise + + expect(postCb.calledOnce, 'post-hook must still fire after hang-guard expires').to.be.true + expect(fastStore.getToken()?.accessToken, 'cachedToken must commit even though pre hung').to.equal('a') + }) + + it('clears the hang-guard timer when the pre-listener wins the race (no leaked Node timer)', async () => { + // Regression for N2 review finding: without clearTimeout, every + // transition leaks a 6s timer that keeps the event loop alive. + // We verify by counting pending timers via the fake clock: after + // a fast callback resolves and the loadToken settles, no setTimeout + // queued by fireBeforeAuthChange should remain. + const fastStore = new AuthStateStore({ + beforeAuthChangeTimeoutMs: HANG_GUARD_MS, + pollIntervalMs: POLL_INTERVAL, + tokenStore, + }) + fastStore.onBeforeAuthChange(async () => { + // resolves on the next microtask — wins the race trivially. + }) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + // Snapshot the pending-timer count before and after the transition. + const before = clock.countTimers() + await fastStore.loadToken() + const after = clock.countTimers() + + expect(after - before, 'no pending timer leaked by the hang-guard').to.equal(0) + }) + + it('runs multiple pre-listeners in registration order, awaiting each in series', async () => { + const order: string[] = [] + store.onBeforeAuthChange(async () => { + await Promise.resolve() + order.push('pre1') + }) + store.onBeforeAuthChange(async () => { + await Promise.resolve() + order.push('pre2') + }) + store.onBeforeAuthChange(async () => { + await Promise.resolve() + order.push('pre3') + }) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + await store.loadToken() + + expect(order).to.deep.equal(['pre1', 'pre2', 'pre3']) + }) + + it('continues to subsequent pre-listeners when an earlier one rejects', async () => { + const cb1 = sandbox.stub().rejects(new Error('pre1 boom')) + const cb2 = sandbox.stub().resolves() + const cb3 = sandbox.stub().resolves() + store.onBeforeAuthChange(cb1) + store.onBeforeAuthChange(cb2) + store.onBeforeAuthChange(cb3) + const postCb = sandbox.stub() + store.onAuthChanged(postCb) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + await store.loadToken() + + expect(cb1.calledOnce).to.be.true + expect(cb2.calledOnce, 'cb2 must run after cb1 rejected').to.be.true + expect(cb3.calledOnce, 'cb3 must run after cb1 rejected').to.be.true + expect(postCb.calledOnce, 'post-hook still fires').to.be.true + }) + + it('passes (oldToken, newToken) to the pre-listener', async () => { + const token1 = createValidToken({accessToken: 'a'}) + loadStub.resolves(token1) + await store.loadToken() + + const cb = sandbox.stub().resolves() + store.onBeforeAuthChange(cb) + + const token2 = createValidToken({accessToken: 'b'}) + loadStub.resolves(token2) + await store.loadToken() + + expect(cb.calledOnce).to.be.true + expect(cb.firstCall.args[0]?.accessToken, 'arg0 is OLD token').to.equal('a') + expect(cb.firstCall.args[1]?.accessToken, 'arg1 is NEW token').to.equal('b') + }) + }) }) diff --git a/test/unit/infra/storage/file-settings-store.test.ts b/test/unit/infra/storage/file-settings-store.test.ts index 6c3a593ac..982ce7944 100644 --- a/test/unit/infra/storage/file-settings-store.test.ts +++ b/test/unit/infra/storage/file-settings-store.test.ts @@ -1,11 +1,16 @@ import {expect} from 'chai' +import {existsSync} from 'node:fs' import {mkdir, readdir, readFile, rm, writeFile} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' +import type {SettingDescriptor} from '../../../../src/server/core/domain/entities/settings.js' + import {FileSettingsStore} from '../../../../src/server/infra/storage/file-settings-store.js' import { InvalidSettingValueError, + ReadonlySettingKeyError, + SettingsValidator, UnknownSettingKeyError, } from '../../../../src/server/infra/storage/settings-validator.js' @@ -49,13 +54,24 @@ describe('FileSettingsStore', () => { expect(keys).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', + 'analytics.share', + 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', 'taskHistory.maxEntries', 'update.checkForUpdates', ]) for (const item of items) { - expect(item.current).to.equal(item.default) + // Non-file-stored rows carry current/default both undefined: + // - analytics.status (readonly-info) + // - analytics.share (storage=global-config) + // Writable file-stored rows have current === default when no override is present. + if (item.key === 'analytics.status' || item.key === 'analytics.share') { + expect(item.current).to.equal(undefined) + expect(item.default).to.equal(undefined) + } else { + expect(item.current).to.equal(item.default) + } } }) @@ -485,4 +501,229 @@ describe('FileSettingsStore', () => { expect(file.values).to.deep.equal(v1Payload.values) }) }) + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + { + category: 'concurrency', + default: 10, + description: 'test writable', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + + let isolatedStore: FileSettingsStore + let isolatedDir: string + + beforeEach(async () => { + isolatedDir = join(tmpdir(), `brv-settings-roi-${Date.now()}-${Math.random().toString(36).slice(2)}`) + await mkdir(isolatedDir, {recursive: true}) + isolatedStore = new FileSettingsStore({ + baseDir: isolatedDir, + registry: readonlyInfoRegistry, + validator: new SettingsValidator({registry: readonlyInfoRegistry}), + }) + }) + + afterEach(async () => { + await rm(isolatedDir, {force: true, recursive: true}) + }) + + describe('set', () => { + it('throws ReadonlySettingKeyError on a readonly-info key', async () => { + try { + await isolatedStore.set('_test.snapshot', 'whatever') + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(ReadonlySettingKeyError) + } + }) + + it('does NOT create the settings file when refusing a readonly-info write', async () => { + try { + await isolatedStore.set('_test.snapshot', 'whatever') + } catch { + // expected + } + + expect(existsSync(join(isolatedDir, SETTINGS_FILENAME))).to.equal(false) + }) + + it('does NOT mutate an existing settings file when refusing a readonly-info write', async () => { + await isolatedStore.set('_test.writable', 25) + const before = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + + try { + await isolatedStore.set('_test.snapshot', 'whatever') + } catch { + // expected + } + + const after = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + expect(after).to.equal(before) + }) + }) + + describe('reset', () => { + it('throws ReadonlySettingKeyError on a readonly-info key', async () => { + try { + await isolatedStore.reset('_test.snapshot') + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(ReadonlySettingKeyError) + } + }) + + it('does NOT mutate the settings file when refusing a readonly-info reset', async () => { + await isolatedStore.set('_test.writable', 25) + const before = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + + try { + await isolatedStore.reset('_test.snapshot') + } catch { + // expected + } + + const after = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + expect(after).to.equal(before) + }) + }) + + describe('get', () => { + it('returns current=undefined and omits default for a readonly-info key', async () => { + const item = await isolatedStore.get('_test.snapshot') + expect(item.key).to.equal('_test.snapshot') + expect(item.current).to.equal(undefined) + expect(item.default).to.equal(undefined) + expect(item.restartRequired).to.equal(false) + }) + + it('still returns descriptor defaults for writable keys alongside readonly-info', async () => { + const item = await isolatedStore.get('_test.writable') + expect(item.key).to.equal('_test.writable') + expect(item.current).to.equal(10) + expect(item.default).to.equal(10) + }) + }) + + describe('global-config storage (M16.2)', () => { + const globalConfigRegistry: readonly SettingDescriptor[] = [ + { + category: 'analytics', + default: false, + description: 'analytics opt-in (global config)', + key: '_test.global', + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, + { + category: 'concurrency', + default: 10, + description: 'test writable file-stored', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + + let gcStore: FileSettingsStore + let gcDir: string + + beforeEach(async () => { + gcDir = join(tmpdir(), `brv-settings-gc-${Date.now()}-${Math.random().toString(36).slice(2)}`) + await mkdir(gcDir, {recursive: true}) + gcStore = new FileSettingsStore({ + baseDir: gcDir, + registry: globalConfigRegistry, + validator: new SettingsValidator({registry: globalConfigRegistry}), + }) + }) + + afterEach(async () => { + await rm(gcDir, {force: true, recursive: true}) + }) + + it('list() returns current=undefined for a global-config-stored key', async () => { + const items = await gcStore.list() + const row = items.find((i) => i.key === '_test.global') + expect(row).to.exist + expect(row?.current).to.equal(undefined) + expect(row?.default).to.equal(undefined) + expect(row?.restartRequired).to.equal(false) + }) + + it('get() returns current=undefined for a global-config-stored key', async () => { + const item = await gcStore.get('_test.global') + expect(item.key).to.equal('_test.global') + expect(item.current).to.equal(undefined) + expect(item.default).to.equal(undefined) + }) + + it('set() throws InvalidSettingValueError for a global-config-stored key', async () => { + try { + await gcStore.set('_test.global', true) + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(InvalidSettingValueError) + } + }) + + it('does NOT create the settings file when refusing a global-config write', async () => { + try { + await gcStore.set('_test.global', true) + } catch { + // expected + } + + expect(existsSync(join(gcDir, SETTINGS_FILENAME))).to.equal(false) + }) + + it('reset() throws InvalidSettingValueError for a global-config-stored key', async () => { + try { + await gcStore.reset('_test.global') + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(InvalidSettingValueError) + } + }) + + it('still serves writable file-stored keys alongside global-config keys', async () => { + const item = await gcStore.get('_test.writable') + expect(item.current).to.equal(10) + expect(item.default).to.equal(10) + }) + }) + + describe('list', () => { + it('includes the readonly-info row with current=undefined and default omitted', async () => { + const items = await isolatedStore.list() + const snapshot = items.find((i) => i.key === '_test.snapshot') + expect(snapshot, 'readonly-info row must be present').to.exist + expect(snapshot?.current).to.equal(undefined) + expect(snapshot?.default).to.equal(undefined) + expect(snapshot?.restartRequired).to.equal(false) + }) + + it('keeps writable rows unaffected by the readonly-info branch', async () => { + const items = await isolatedStore.list() + const writable = items.find((i) => i.key === '_test.writable') + expect(writable?.current).to.equal(10) + expect(writable?.default).to.equal(10) + }) + }) + }) }) diff --git a/test/unit/infra/storage/settings-validator.test.ts b/test/unit/infra/storage/settings-validator.test.ts index 0a3aa2d96..322671969 100644 --- a/test/unit/infra/storage/settings-validator.test.ts +++ b/test/unit/infra/storage/settings-validator.test.ts @@ -1,7 +1,10 @@ import {expect} from 'chai' +import type {SettingDescriptor} from '../../../../src/server/core/domain/entities/settings.js' + import { InvalidSettingValueError, + ReadonlySettingKeyError, SettingsValidator, UnknownSettingKeyError, } from '../../../../src/server/infra/storage/settings-validator.js' @@ -295,4 +298,137 @@ describe('SettingsValidator', () => { expect(result.invalid[0].value).to.equal('yes') }) }) + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + ] + + describe('constructor accepts an injected registry override', () => { + it('validateKey resolves keys from the injected registry only', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + expect(isolated.validateKey('_test.snapshot').type).to.equal('readonly-info') + expect(() => isolated.validateKey('agentPool.maxSize')).to.throw(UnknownSettingKeyError) + }) + }) + + describe('validate', () => { + it('throws ReadonlySettingKeyError when called on a readonly-info key', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + expect(() => isolated.validate('_test.snapshot', {q: 1})).to.throw(ReadonlySettingKeyError) + }) + + it('names the offending key on ReadonlySettingKeyError', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + try { + isolated.validate('_test.snapshot', 1) + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(ReadonlySettingKeyError) + if (error instanceof ReadonlySettingKeyError) { + expect(error.key).to.equal('_test.snapshot') + expect(error.message).to.match(/read-only|readonly/i) + } + } + }) + }) + + describe('partition', () => { + it('pushes a readonly-info key found on disk into invalid with a readonly reason', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + const result = isolated.partition({'_test.snapshot': {q: 1}}) + expect(result.valid).to.deep.equal({}) + expect(result.invalid).to.have.lengthOf(1) + expect(result.invalid[0].key).to.equal('_test.snapshot') + expect(result.invalid[0].reason.toLowerCase()).to.include('read') + }) + + it('omits readonly-info keys from the valid record when the file does NOT mention them', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + const result = isolated.partition({}) + expect(result.valid).to.deep.equal({}) + expect(result.invalid).to.deep.equal([]) + }) + + it('still partitions writable keys correctly when mixed with global-config-stored entries (M16.2)', () => { + const mixedRegistry: readonly SettingDescriptor[] = [ + { + category: 'analytics', + default: false, + description: 'analytics opt-in (global config)', + key: '_test.global', + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, + { + category: 'concurrency', + default: 10, + description: 'file-stored writable', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + const isolated = new SettingsValidator({registry: mixedRegistry}) + const result = isolated.partition({ + '_test.global': true, + '_test.writable': 25, + }) + expect(result.valid).to.deep.equal({'_test.writable': 25}) + expect(result.invalid).to.have.lengthOf(1) + expect(result.invalid[0].key).to.equal('_test.global') + expect(result.invalid[0].reason.toLowerCase()).to.match(/config\.json|global/) + }) + + it('throws InvalidSettingValueError when validate() is called on a global-config-stored key (M16.2)', () => { + const isolated = new SettingsValidator({ + registry: [ + { + category: 'analytics', + default: false, + description: 'analytics opt-in (global config)', + key: '_test.global', + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, + ], + }) + expect(() => isolated.validate('_test.global', true)).to.throw(InvalidSettingValueError, /config\.json|global/) + }) + + it('still partitions writable keys correctly when mixed with readonly-info entries', () => { + const mixedRegistry: readonly SettingDescriptor[] = [ + ...readonlyInfoRegistry, + { + category: 'concurrency', + default: 10, + description: 'test writable', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + const isolated = new SettingsValidator({registry: mixedRegistry}) + const result = isolated.partition({ + '_test.snapshot': {q: 1}, + '_test.writable': 25, + }) + expect(result.valid).to.deep.equal({'_test.writable': 25}) + expect(result.invalid).to.have.lengthOf(1) + expect(result.invalid[0].key).to.equal('_test.snapshot') + }) + }) + }) }) diff --git a/test/unit/infra/transport/handlers/auth-handler.test.ts b/test/unit/infra/transport/handlers/auth-handler.test.ts index 945a57354..161e0147d 100644 --- a/test/unit/infra/transport/handlers/auth-handler.test.ts +++ b/test/unit/infra/transport/handlers/auth-handler.test.ts @@ -3,6 +3,7 @@ import type {SinonStubbedInstance} from 'sinon' import {expect} from 'chai' import {restore, stub} from 'sinon' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type {IAuthService} from '../../../../../src/server/core/interfaces/auth/i-auth-service.js' import type {ICallbackHandler} from '../../../../../src/server/core/interfaces/auth/i-callback-handler.js' import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' @@ -10,6 +11,7 @@ import type {IProviderConfigStore} from '../../../../../src/server/core/interfac import type {IBrowserLauncher} from '../../../../../src/server/core/interfaces/services/i-browser-launcher.js' import type {IUserService} from '../../../../../src/server/core/interfaces/services/i-user-service.js' import type {IAuthStateStore} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' +import type {IGlobalConfigRotator} from '../../../../../src/server/core/interfaces/storage/i-global-config-rotator.js' import type {IProjectConfigStore} from '../../../../../src/server/core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' @@ -18,6 +20,7 @@ import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-conf import {User} from '../../../../../src/server/core/domain/entities/user.js' import {TransportDaemonEventNames} from '../../../../../src/server/core/domain/transport/schemas.js' import {AuthHandler, type AuthHandlerDeps} from '../../../../../src/server/infra/transport/handlers/auth-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {AuthEvents} from '../../../../../src/shared/transport/events/auth-events.js' // ==================== Test Helpers ==================== @@ -82,6 +85,61 @@ function createTestBrvConfig(): BrvConfig { // ==================== Tests ==================== +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +/** + * `failure_kind` discipline: the emitted tag MUST be a coarse enum-like + * value — non-empty, ≤64 chars, snake_case (lowercase letters + + * underscores). Forbids whitespace, newlines, capital letters, symbols — + * catches a developer accidentally passing `getErrorMessage(error)` or + * `error.message` as the tag. + */ +function assertFailureKindDiscipline(value: unknown, label: string): void { + expect(value, `${label}: failure_kind must be a string`).to.be.a('string') + const tag = value as string + expect(tag.length, `${label}: failure_kind must be non-empty`).to.be.greaterThan(0) + expect(tag.length, `${label}: failure_kind must be ≤64 chars (got ${tag.length})`).to.be.lessThanOrEqual(64) + expect(tag, `${label}: failure_kind must be snake_case (a-z0-9 + _), got "${tag}"`).to.match(/^[a-z][a-z0-9_]*$/) +} + +function makeRotatorStub(rotated = true): IGlobalConfigRotator & {rotateSpy: ReturnType} { + const rotateSpy = stub().resolves(rotated) + return { + rotateDeviceId: rotateSpy, + rotateSpy, + } as unknown as IGlobalConfigRotator & {rotateSpy: ReturnType} +} + +function makeTokenForUser(userId: string): AuthToken { + return new AuthToken({ + accessToken: 'access', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh', + sessionKey: 'session', + tokenType: 'Bearer', + userEmail: `${userId}@example.com`, + userId, + }) +} + +function tokenStoreWithPrevious(previous?: AuthToken): ITokenStore { + return { + clear: stub().resolves(), + load: stub().resolves(previous), + save: stub().resolves(), + } as unknown as ITokenStore +} + function makeValidTokenStoreFixture(): ITokenStore { return { clear: stub().resolves(), @@ -558,6 +616,686 @@ describe('AuthHandler — setupExternalAuthSync', () => { }) }) + describe('analytics emits', () => { + it('emits auth_logout with outcome=success on the happy logout path', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'auth_logout fires exactly once on success').to.equal(1) + expect(trackCalls[0].args[1]).to.deep.equal({outcome: 'success'}) + }) + + it('emits auth_logout with outcome=failure when the logout flow throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + const tokenStore = { + clear: stub().rejects(new Error('disk full')), + load: stub().resolves(), + save: stub().resolves(), + } as unknown as ITokenStore + + createHandler({analyticsClient, tokenStore}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: false}) + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'auth_logout fires exactly once on failure').to.equal(1) + const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + assertFailureKindDiscipline(props.failure_kind, 'auth_logout failure emit') + }) + + it('does not throw when analyticsClient.track throws on logout (analytics failures are swallowed)', async () => { + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.throws(new Error('boom')) + + createHandler({analyticsClient}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + + it('is a no-op when no analyticsClient is injected (optional dep, backward compat)', async () => { + createHandler() // no analyticsClient override + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + + it('emits auth_login with outcome=success on API-key login after token save + loadToken', async () => { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const tokenStore = { + clear: stub().resolves(), + load: stub().resolves(), + save: stub().callsFake(async () => { + callOrder.push('tokenStore.save') + }), + } as unknown as ITokenStore + authStateStore.loadToken = stub().callsFake(async () => { + callOrder.push('authStateStore.loadToken') + }) as unknown as typeof authStateStore.loadToken + + createHandler({analyticsClient, tokenStore}) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'test-key'}, 'client-1') + + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGIN) + expect(trackCalls.length, 'auth_login fires exactly once on API-key success').to.equal(1) + expect(trackCalls[0].args[1]).to.deep.equal({outcome: 'success'}) + expect(callOrder.indexOf('tokenStore.save'), 'save should precede track').to.be.lessThan( + callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`), + ) + expect(callOrder.indexOf('authStateStore.loadToken'), 'loadToken should precede track').to.be.lessThan( + callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`), + ) + }) + + it('emits auth_login with outcome=failure when API-key login throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + userService.getCurrentUser = stub().rejects(new Error('invalid key')) as unknown as typeof userService.getCurrentUser + + createHandler({analyticsClient}) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + const result = await handler({apiKey: 'bad-key'}, 'client-1') + + expect(result.success).to.equal(false) + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGIN) + expect(trackCalls.length, 'auth_login fires exactly once on API-key failure').to.equal(1) + const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + assertFailureKindDiscipline(props.failure_kind, 'auth_login API-key failure emit') + }) + }) + + describe('setupRefresh — failure path treats as full sign-out', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function makeRefreshHarness(opts: { + previousToken: AuthToken | undefined + refreshThrows: boolean + rotator: IGlobalConfigRotator + }): { + analyticsClient: ReturnType + callOrder: string[] + callRefresh: () => Promise + tokenStore: ITokenStore & {clearSpy: ReturnType} + } { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const clearSpy = stub().callsFake(async () => { + callOrder.push('tokenStore.clear') + }) + const tokenStore = { + clear: clearSpy, + clearSpy, + load: stub().resolves(opts.previousToken), + save: stub().resolves(), + } as unknown as ITokenStore & {clearSpy: ReturnType} + + const refreshStub = opts.refreshThrows + ? stub().rejects(new Error('refresh denied')) + : stub().resolves({ + accessToken: 'new-a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'new-r', + sessionKey: 'new-s', + tokenType: 'Bearer', + }) + + const localTransport = createMockTransport() + const localBroadcast = stub().callsFake((event: string) => { + if (event === AuthEvents.STATE_CHANGED) callOrder.push('broadcast:STATE_CHANGED') + }) + ;(localTransport as unknown as {broadcast: typeof localBroadcast}).broadcast = localBroadcast + + new AuthHandler({ + analyticsClient, + authService: { + exchangeCodeForToken: stub(), + initiateAuthorization: stub(), + refreshToken: refreshStub, + } as unknown as IAuthService, + authStateStore, + browserLauncher: {open: stub()} as unknown as IBrowserLauncher, + callbackHandler: { + getPort: stub().returns(3000), + start: stub().resolves(), + stop: stub().resolves(), + waitForCallback: stub().resolves({code: 'test'}), + } as unknown as ICallbackHandler, + globalConfigRotator: opts.rotator, + projectConfigStore, + providerConfigStore, + resolveProjectPath: stub().returns('/test/project'), + tokenStore, + transport: localTransport, + userService, + }).setup() + + return { + analyticsClient, + callOrder, + async callRefresh() { + const handler = localTransport._handlers.get(AuthEvents.REFRESH)! + return handler(undefined, 'client-1') + }, + tokenStore, + } + } + + it('clears the token, emits auth_logout {failure_kind:"refresh_failed"}, rotates, and broadcasts STATE_CHANGED', async () => { + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: true, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + expect(harness.tokenStore.clearSpy.calledOnce, 'token cleared on refresh failure').to.be.true + expect(rotator.rotateSpy.calledOnce, 'device_id rotated on refresh failure').to.be.true + + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'auth_logout fires once on refresh-fail sign-out').to.equal(1) + const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('refresh_failed') + assertFailureKindDiscipline(props.failure_kind, 'refresh-fail sign-out emit') + + expect(harness.callOrder, 'STATE_CHANGED broadcast fired on refresh-fail').to.include('broadcast:STATE_CHANGED') + }) + + it('does NOT rotate when the previous token is expired (no live identity)', async () => { + const expired = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() - 60_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'old@example.com', + userId: 'user-OLD', + }) + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({previousToken: expired, refreshThrows: true, rotator}) + + await harness.callRefresh() + + expect(rotator.rotateSpy.called, 'expired token before refresh means no live identity to retire').to.be.false + }) + + it('early-returns success=false without emitting or rotating when no token is loaded', async () => { + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({previousToken: undefined, refreshThrows: false, rotator}) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + expect(rotator.rotateSpy.called, 'no rotation when there was nothing to refresh').to.be.false + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'no auth_logout emit on the early-return branch').to.equal(0) + }) + + it('does NOT rotate or emit on successful refresh (same user, token replaced)', async () => { + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: false, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.called, 'successful refresh keeps the same identity').to.be.false + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'no auth_logout on successful refresh').to.equal(0) + }) + + it('disconnects the byterover provider (symmetric with logout success + onAuthExpired)', async () => { + providerConfigStore = createMockProviderConfigStore({isConnected: true}) + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: true, + rotator, + }) + + await harness.callRefresh() + + expect(providerConfigStore.disconnectProvider.calledOnceWith('byterover'), 'byterover must be disconnected on refresh-fail sign-out').to.be.true + }) + + it('does NOT treat post-refresh user-fetch failure as a refresh-fail sign-out (narrow catch)', async () => { + // refreshToken() succeeds; userService.getCurrentUser() then 5xx-s. + // This is a post-refresh APPLICATION failure — not a refresh-fail. + // The narrowed catch must NOT clear, rotate, emit auth_logout, or + // broadcast isAuthorized:false. + userService.getCurrentUser = stub().rejects(new Error('5xx')) as unknown as typeof userService.getCurrentUser + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: false, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + expect(harness.tokenStore.clearSpy.called, 'token must NOT be cleared on post-refresh failure').to.be.false + expect(rotator.rotateSpy.called, 'device_id must NOT rotate on post-refresh failure').to.be.false + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'no auth_logout emit on post-refresh failure').to.equal(0) + expect( + harness.callOrder.includes('broadcast:STATE_CHANGED'), + 'no isAuthorized:false broadcast on post-refresh failure', + ).to.be.false + }) + + it('does NOT throw when rotation fails on the refresh sign-out path', async () => { + const rotator = makeRotatorStub() + rotator.rotateSpy.rejects(new Error('disk full')) + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: true, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + }) + }) + + describe('device_id rotation on login — account switch', () => { + describe('API-key path', () => { + it('does NOT rotate on fresh login (no previous token)', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.called, 'fresh login does not retire a non-existent identity').to.be.false + }) + + it('does NOT rotate when re-asserting the same user', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-123')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.called, 'same userId means no switch').to.be.false + }) + + it('rotates AFTER the auth_login emit when previous user differs from new user', async () => { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const rotator = makeRotatorStub() + rotator.rotateSpy.callsFake(async () => { + callOrder.push('rotate') + return true + }) + + createHandler({ + analyticsClient, + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-OLD')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.calledOnce, 'rotation runs exactly once on switch').to.be.true + expect(callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`), 'emit happens before rotation').to.be.lessThan( + callOrder.indexOf('rotate'), + ) + }) + + it('does NOT rotate when the previous token is expired (not a live identity)', async () => { + const expired = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() - 60_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'old@example.com', + userId: 'user-OLD', + }) + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(expired), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.called, 'expired token does not count as a live previous identity').to.be.false + }) + + it('does NOT fail the login RPC when rotation throws', async () => { + const rotator = makeRotatorStub() + rotator.rotateSpy.rejects(new Error('disk full')) + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-OLD')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + const result = await handler({apiKey: 'k'}, 'client-1') + + expect(result.success).to.equal(true) + }) + + it('does NOT rotate on the login failure branch (no token committed)', async () => { + const rotator = makeRotatorStub() + userService.getCurrentUser = stub().rejects( + new Error('invalid key'), + ) as unknown as typeof userService.getCurrentUser + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-OLD')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + const result = await handler({apiKey: 'bad'}, 'client-1') + + expect(result.success).to.equal(false) + expect(rotator.rotateSpy.called, 'failed login never claims the device for the new user').to.be.false + }) + }) + + describe('OAuth (processLoginCallback) path', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function setupOAuthHandler(opts: {previousToken: AuthToken | undefined; rotator: IGlobalConfigRotator}): { + callOrder: string[] + run: () => Promise + } { + const callOrder: string[] = [] + const oauthTransport = createMockTransport() + const oauthAuthStateStore = { + getToken: stub(), + loadToken: stub().callsFake(async () => { + callOrder.push('loadToken') + }), + onAuthChanged: stub(), + onAuthExpired: stub(), + startPolling: stub(), + stopPolling: stub(), + } as unknown as SinonStubbedInstance + + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + + new AuthHandler({ + analyticsClient, + authService: { + exchangeCodeForToken: stub().resolves({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + }), + initiateAuthorization: stub().returns({authUrl: 'https://auth.test', state: 'st'}), + refreshToken: stub(), + } as unknown as IAuthService, + authStateStore: oauthAuthStateStore, + browserLauncher: {open: stub().resolves()} as unknown as IBrowserLauncher, + callbackHandler: { + getPort: stub().returns(3000), + start: stub().resolves(), + stop: stub().resolves(), + waitForCallback: stub().resolves({code: 'c'}), + } as unknown as ICallbackHandler, + globalConfigRotator: opts.rotator, + projectConfigStore, + providerConfigStore: createMockProviderConfigStore(), + resolveProjectPath: stub().returns('/test'), + tokenStore: { + clear: stub().resolves(), + load: stub().resolves(opts.previousToken), + save: stub().callsFake(async () => { + callOrder.push('tokenStore.save') + }), + } as unknown as ITokenStore, + transport: oauthTransport, + userService, + }).setup() + + return { + callOrder, + async run() { + const handler = oauthTransport._handlers.get(AuthEvents.START_LOGIN)! + await handler({}, 'client-1') + // Wait for fire-and-forget processLoginCallback to finish. + await new Promise((resolve) => { + setTimeout(resolve, 50) + }) + }, + } + } + + it('does NOT rotate on fresh OAuth login (no previous token)', async () => { + const rotator = makeRotatorStub() + const harness = setupOAuthHandler({previousToken: undefined, rotator}) + + await harness.run() + + expect(rotator.rotateSpy.called).to.be.false + }) + + it('does NOT rotate when OAuth re-issues for the same user', async () => { + const rotator = makeRotatorStub() + const sameUserToken = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'test@example.com', + userId: 'user-123', + }) + const harness = setupOAuthHandler({previousToken: sameUserToken, rotator}) + + await harness.run() + + expect(rotator.rotateSpy.called).to.be.false + }) + + it('rotates AFTER the auth_login emit when OAuth switches users', async () => { + const rotator = makeRotatorStub() + const otherUserToken = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'old@example.com', + userId: 'user-OLD', + }) + const harness = setupOAuthHandler({previousToken: otherUserToken, rotator}) + rotator.rotateSpy.callsFake(async () => { + harness.callOrder.push('rotate') + return true + }) + + await harness.run() + + expect(rotator.rotateSpy.calledOnce).to.be.true + expect(harness.callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`)).to.be.lessThan( + harness.callOrder.indexOf('rotate'), + ) + }) + }) + }) + + describe('device_id rotation on logout', () => { + it('rotates device_id AFTER the auth_logout emit when previously authenticated', async () => { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const rotator = makeRotatorStub() + rotator.rotateSpy.callsFake(async () => { + callOrder.push('rotate') + return true + }) + + createHandler({ + analyticsClient, + globalConfigRotator: rotator, + tokenStore: makeValidTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.calledOnce, 'rotateDeviceId called once on authenticated logout').to.be.true + expect(callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGOUT}`), 'emit happens before rotation').to.be.lessThan( + callOrder.indexOf('rotate'), + ) + }) + + it('does NOT rotate when token store returns undefined (already-anonymous logout)', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: makeMissingTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.called, 'no rotation on already-anonymous logout').to.be.false + }) + + it('does NOT rotate when the stored token is expired (treated as already-anonymous)', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: makeExpiredTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.called, 'expired token means no live identity to retire').to.be.false + }) + + it('does NOT fail the logout RPC when rotation throws', async () => { + const rotator = makeRotatorStub() + rotator.rotateSpy.rejects(new Error('disk full')) + + createHandler({ + globalConfigRotator: rotator, + tokenStore: makeValidTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + + it('does NOT rotate on the logout failure branch (indeterminate identity)', async () => { + const rotator = makeRotatorStub() + const tokenStore = { + clear: stub().rejects(new Error('disk full')), + load: stub().resolves(createValidToken()), + save: stub().resolves(), + } as unknown as ITokenStore + + createHandler({globalConfigRotator: rotator, tokenStore}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: false}) + expect(rotator.rotateSpy.called, 'rotation skipped when logout flow failed mid-way').to.be.false + }) + + it('is a no-op when no globalConfigRotator is injected (optional dep)', async () => { + // Mirrors the existing optional-analyticsClient backward-compat pattern. + createHandler({tokenStore: makeValidTokenStoreFixture()}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + }) + + describe('opportunistic token expiry — onAuthExpired callback', () => { + it('does NOT rotate device_id (passive expiry is not a sign-out trigger)', () => { + const rotator = makeRotatorStub() + createHandler({globalConfigRotator: rotator}) + + capturedAuthExpired!(createValidToken()) + + expect(rotator.rotateSpy.called, 'polling-observed expiry is out of scope for rotation').to.be.false + }) + }) + describe('setupGetState', () => { it('returns isAuthorized=true and skips brvConfig when body is undefined (TUI sends no body)', async () => { createHandler({tokenStore: makeValidTokenStoreFixture()}) diff --git a/test/unit/infra/transport/handlers/connectors-handler.test.ts b/test/unit/infra/transport/handlers/connectors-handler.test.ts new file mode 100644 index 000000000..03a2418dd --- /dev/null +++ b/test/unit/infra/transport/handlers/connectors-handler.test.ts @@ -0,0 +1,167 @@ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {ConnectorsHandler} from '../../../../../src/server/infra/transport/handlers/connectors-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ConnectorEvents} from '../../../../../src/shared/transport/events/connector-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +describe('ConnectorsHandler — connector_installed analytics', () => { + let transport: MockTransportServer + + beforeEach(() => { + transport = createMockTransportServer() + }) + + afterEach(() => { + restore() + }) + + type SwitchOutcome = 'failure' | 'success' | 'throw' + function createHandler(opts: { + analyticsClient?: IAnalyticsClient + switchOutcome?: SwitchOutcome + }): {connectorManagerFactory: ReturnType} { + const installResult = {configPath: '/cfg', manualInstructions: '', requiresManualSetup: false} + let switchStub: ReturnType + switch (opts.switchOutcome ?? 'success') { + case 'failure': { + switchStub = stub().resolves({installResult, message: 'failed', success: false}) + break + } + + case 'throw': { + switchStub = stub().rejects(new Error('switch boom')) + break + } + + default: { + switchStub = stub().resolves({installResult, message: 'ok', success: true}) + } + } + + const connectorManagerFactory = stub().returns({ + getAllInstalledConnectors: stub().resolves(new Map()), + getConnector: stub(), + getDefaultConnectorType: stub(), + getSupportedConnectorTypes: stub().returns([]), + switchConnector: switchStub, + }) + new ConnectorsHandler({ + analyticsClient: opts.analyticsClient, + connectorManagerFactory: connectorManagerFactory as never, + resolveProjectPath: stub().returns('/proj') as never, + transport, + }).setup() + return {connectorManagerFactory} + } + + async function callInstall(data: {agentId: string; connectorType: string}): Promise { + const handler = transport._handlers.get(ConnectorEvents.INSTALL) + expect(handler, 'connectors:install handler should be registered').to.exist + return handler!(data, 'client-1') + } + + it('emits connector_installed outcome=success when switchConnector resolves success=true', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient, switchOutcome: 'success'}) + + await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {agent_target: string; connector_id: string; outcome: string} + expect(props.outcome).to.equal('success') + expect(props.agent_target).to.equal('Claude Code') + expect(props.connector_id).to.equal('rules') + }) + + it('emits connector_installed outcome=failure with failure_kind=install_failed when switchConnector returns success=false', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient, switchOutcome: 'failure'}) + + await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('install_failed') + }) + + it('emits connector_installed outcome=failure with failure_kind=invalid_agent on bad agentId', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const result = await callInstall({agentId: 'not-a-real-agent', connectorType: 'rules'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('invalid_agent') + }) + + it('emits connector_installed outcome=failure with failure_kind=invalid_connector on bad connectorType', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const result = await callInstall({agentId: 'Claude Code', connectorType: 'not-a-real-connector'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('invalid_connector') + }) + + it('does NOT emit when switchConnector throws (no handler-level catch — error propagates uncaught)', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient, switchOutcome: 'throw'}) + + let threw = false + try { + await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + } catch { + threw = true + } + + expect(threw, 'thrown errors should propagate to caller').to.equal(true) + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length, 'no emit on thrown failure without an existing catch').to.equal(0) + }) + + it('is a no-op when no analyticsClient is injected (backward-compat)', async () => { + createHandler({switchOutcome: 'success'}) + + const result = await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + expect(result).to.deep.include({success: true}) + }) +}) diff --git a/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts b/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts new file mode 100644 index 000000000..09a026972 --- /dev/null +++ b/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts @@ -0,0 +1,156 @@ +import {expect} from 'chai' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IContextFileReader} from '../../../../../src/server/core/interfaces/context-tree/i-context-file-reader.js' +import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' +import type {IGitService} from '../../../../../src/server/core/interfaces/services/i-git-service.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {ContextTreeHandler} from '../../../../../src/server/infra/transport/handlers/context-tree-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ContextTreeEvents} from '../../../../../src/shared/transport/events/context-tree-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ContextTreeHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let contextTreeDir: string + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + projectDir = mkdtempSync(join(tmpdir(), 'brv-ct-proj-')) + contextTreeDir = join(projectDir, '.brv', 'context-tree') + + const contextTreeService = { + delete: sandbox.stub().resolves(), + exists: sandbox.stub().resolves(true), + hasGitRepo: sandbox.stub().resolves(false), + initialize: sandbox.stub().resolves(contextTreeDir), + resolvePath: sandbox.stub().returns(contextTreeDir), + } as unknown as IContextTreeService + + const contextFileReader: IContextFileReader = {read: sandbox.stub().resolves()} as never + const gitService = {log: sandbox.stub().resolves([])} as unknown as Pick + + analyticsClient = makeFakeAnalyticsClient() + new ContextTreeHandler({ + analyticsClient, + contextFileReader, + contextTreeService, + gitService, + resolveProjectPath: sandbox.stub().returns(projectDir), + transport, + }).setup() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + }) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits context_tree_file_edited outcome=success with byte_delta + hashed paths', async () => { + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'new-content', path: 'topic.md'}, 'c1') + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + byte_delta?: number + file_relative_path_hash: string + outcome: string + project_path_hash: string + } + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + expect(props.file_relative_path_hash).to.match(sha256HexRegex) + expect(props.byte_delta).to.equal(11) + }) + + it('emits context_tree_file_edited outcome=success with negative byte_delta on shrink', async () => { + // Pre-create the file so we have a baseline + const {mkdirSync} = await import('node:fs') + mkdirSync(contextTreeDir, {recursive: true}) + writeFileSync(join(contextTreeDir, 'topic.md'), 'old much longer content here') + + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'tiny', path: 'topic.md'}, 'c1') + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + const props = calls[0].args[1] as {byte_delta?: number} + expect(props.byte_delta).to.be.lessThan(0) + }) + + it('emits context_tree_file_edited outcome=failure failure_kind=invalid_path on path traversal', async () => { + try { + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'x', path: '../../etc/passwd'}, 'c1') + expect.fail('should have thrown') + } catch (error) { + expect((error as Error).message).to.include('traversal') + } + + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('invalid_path') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('regression: raw path never appears in emit (only sha256 hash)', async () => { + const secretPath = 'super-secret-topic-name.md' + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'x', path: secretPath}, 'c1') + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + const props = calls[0].args[1] as Record + expect(JSON.stringify(props)).to.not.include('super-secret-topic-name.md') + }) + + it('does NOT emit on read-only events (GET_FILE / GET_NODES / GET_HISTORY)', async () => { + try { + await requestHandlers[ContextTreeEvents.GET_NODES]({}, 'c1') + } catch { + // swallow — readonly handler may error in this stubbed env + } + + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts new file mode 100644 index 000000000..d3ee25f8b --- /dev/null +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -0,0 +1,567 @@ +import type {SinonStubbedInstance} from 'sinon' + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {GLOBAL_CONFIG_VERSION} from '../../../../../src/server/constants.js' +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {GlobalConfigHandler} from '../../../../../src/server/infra/transport/handlers/global-config-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {GlobalConfigEvents} from '../../../../../src/shared/transport/events/global-config-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +function createMockGlobalConfigStore(): SinonStubbedInstance { + return { + read: stub<[], Promise>().resolves(), + write: stub<[GlobalConfig], Promise>().resolves(), + } +} + +// M4.4: minimal analytics client double whose only relevant member for +// the disable-side-effect tests is `abort`. Hoisted to module scope to +// satisfy `unicorn/consistent-function-scoping`. +function makeAnalyticsClientStub(): {abort: ReturnType} { + return {abort: stub()} +} + +// Full analytics client double for the analytics_disabled emit tests. +// Same module-scope hoist rationale as makeAnalyticsClientStub above. +function makeTrackingClient(): { + abort: ReturnType + flush: ReturnType + getRuntimeState: ReturnType + onAuthTransition: ReturnType + track: ReturnType +} { + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: stub(), + } +} + +describe('GlobalConfigHandler', () => { + let store: SinonStubbedInstance + let transport: MockTransportServer + let handler: GlobalConfigHandler + + beforeEach(() => { + store = createMockGlobalConfigStore() + transport = createMockTransportServer() + handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + }) + + afterEach(() => { + restore() + }) + + async function callGet(): Promise<{analytics: boolean; deviceId: string; version: string}> { + const fn = transport._handlers.get(GlobalConfigEvents.GET) + if (!fn) throw new Error(`handler not registered: ${GlobalConfigEvents.GET}`) + return fn(undefined, 'client-1') + } + + async function callSet(analytics: boolean): Promise<{current: boolean; previous: boolean}> { + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error(`handler not registered: ${GlobalConfigEvents.SET_ANALYTICS}`) + return fn({analytics}, 'client-1') + } + + describe('setup', () => { + it('registers GET and SET_ANALYTICS handlers', () => { + expect(transport._handlers.has(GlobalConfigEvents.GET)).to.be.true + expect(transport._handlers.has(GlobalConfigEvents.SET_ANALYTICS)).to.be.true + }) + }) + + describe('getCachedAnalytics', () => { + it('throws before refreshCache() resolves', () => { + expect(() => handler.getCachedAnalytics()).to.throw(/refreshCache/) + }) + + it('returns the cached flag after refreshCache() populates from disk', async () => { + const config = GlobalConfig.create('device-abc').withAnalytics(true) + store.read.resolves(config) + + await handler.refreshCache() + + expect(handler.getCachedAnalytics()).to.be.true + }) + }) + + describe('refreshCache', () => { + it('sets cache to false when no config exists on disk', async () => { + store.read.resolves() + + await handler.refreshCache() + + expect(handler.getCachedAnalytics()).to.be.false + }) + + it('swallows store.read errors and sets cache to false (fail-safe)', async () => { + store.read.rejects(new Error('disk failure')) + + await handler.refreshCache() + + expect(handler.getCachedAnalytics()).to.be.false + }) + }) + + describe('GET handler', () => { + it('returns disk values when config exists', async () => { + const config = GlobalConfig.create('device-xyz').withAnalytics(true) + store.read.resolves(config) + + const result = await callGet() + + expect(result).to.deep.equal({ + analytics: true, + deviceId: 'device-xyz', + version: config.version, + }) + expect(store.write.called, 'must not write on read').to.be.false + }) + + it('returns synthetic defaults and does NOT write when no config exists (D1 invariant)', async () => { + store.read.resolves() + + const result = await callGet() + + expect(result).to.deep.equal({ + analytics: false, + deviceId: '', + version: GLOBAL_CONFIG_VERSION, + }) + expect(store.write.called, 'read() must be pure — no write on missing config').to.be.false + }) + + it('updates the cached flag when config exists', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(true) + store.read.resolves(config) + + await callGet() + + expect(handler.getCachedAnalytics()).to.be.true + }) + }) + + describe('SET_ANALYTICS handler', () => { + it('idempotent fast-path: no write when requested value matches current', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(true) + store.read.resolves(config) + + const result = await callSet(true) + + expect(result).to.deep.equal({current: true, previous: true}) + expect(store.write.called, 'must not write on idempotent SET').to.be.false + }) + + it('idempotent fast-path: no write when toggling from default (no config) to false', async () => { + store.read.resolves() + + const result = await callSet(false) + + expect(result).to.deep.equal({current: false, previous: false}) + expect(store.write.called, 'must not seed a config just to match the default').to.be.false + }) + + it('round-trip: writes updated config and returns previous/current', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(false) + store.read.resolves(config) + + const result = await callSet(true) + + expect(result).to.deep.equal({current: true, previous: false}) + expect(store.write.calledOnce).to.be.true + const written = store.write.firstCall.args[0] + expect(written.deviceId).to.equal('device-1') + expect(written.analytics).to.be.true + }) + + it('seeds a new deviceId when enabling for the first time (no config on disk)', async () => { + store.read.resolves() + + const result = await callSet(true) + + expect(result.current).to.be.true + expect(result.previous).to.be.false + expect(store.write.calledOnce).to.be.true + const written = store.write.firstCall.args[0] + expect(written.deviceId.length).to.be.greaterThan(0) + expect(written.analytics).to.be.true + }) + + it('updates the cached flag after a successful write', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(false) + store.read.resolves(config) + + await callSet(true) + + expect(handler.getCachedAnalytics()).to.be.true + }) + + it('serializes concurrent enables from a fresh install: writes once, single deviceId persists', async () => { + // Both callers observe the same fresh-install (no config). Without + // serialization both would create a different deviceId and both would + // write — last-write wins and the loser's response carries a deviceId + // that no longer exists on disk. With serialization the first writes + // a fresh uuid and the second hits the idempotent fast-path. + store.read.resolves() + const writtenDeviceIds: string[] = [] + store.write.callsFake(async (cfg: GlobalConfig) => { + // Simulate the on-disk seeding so the second serialized caller's + // read sees the now-written config. + writtenDeviceIds.push(cfg.deviceId) + store.read.resolves(cfg) + }) + + const [first, second] = await Promise.all([callSet(true), callSet(true)]) + + expect(store.write.callCount, 'concurrent enables must serialize to a single write').to.equal(1) + expect(writtenDeviceIds, 'exactly one deviceId persisted').to.have.lengthOf(1) + expect(first.current).to.be.true + expect(second.current).to.be.true + }) + }) + + describe('M4.4 abort-on-disable side effect', () => { + // Disable does NOT drop the queue or clear JSONL — those stay so a + // future re-enable ships the backlog. The only side effect is + // cancelling an in-flight HTTP send so the daemon doesn't + // half-ship a batch across an enable/disable boundary. + + it('calls analyticsClient.abort() exactly once when analytics flips true → false', async () => { + const analyticsClient = makeAnalyticsClientStub() + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort: analyticsClient.abort, + flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + // Seed disk as currently enabled. + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + // Now disable. + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + expect(analyticsClient.abort.calledOnce, 'abort must fire on enable→disable transition').to.be.true + }) + + it('does NOT call abort() when the disable is an idempotent no-op (already disabled)', async () => { + const analyticsClient = makeAnalyticsClientStub() + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort: analyticsClient.abort, + flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + // Already disabled (or never enabled). previous === false, requested === false. + store.read.resolves() + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + expect(analyticsClient.abort.called, 'no transition = no abort').to.be.false + }) + + it('does NOT call abort() when the user enables (false → true)', async () => { + const analyticsClient = makeAnalyticsClientStub() + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort: analyticsClient.abort, + flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + const disabled = GlobalConfig.create('device-x').withAnalytics(false) + store.read.resolves(disabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: true}, 'client-1') + + expect(analyticsClient.abort.called, 'enable is not a transition that requires abort').to.be.false + }) + + it('still completes the config write when abort() throws', async () => { + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort() { + throw new Error('abort boom') + }, + flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + const response = await fn({analytics: false}, 'client-1') + + expect(response.current, 'config write must complete even if abort threw').to.be.false + expect(response.previous).to.be.true + expect(store.write.calledOnce, 'config flush still happens').to.be.true + }) + + it('does not require analyticsClient (backwards-compat: dep is optional)', async () => { + // Pre-M4.4 callers (or test harnesses) don't wire analyticsClient. + // The handler must still work — the abort side-effect is skipped. + const handlerNoClient = new GlobalConfigHandler({globalConfigStore: store, transport}) + handlerNoClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + const response = await fn({analytics: false}, 'client-1') + + expect(response.current, 'works without analyticsClient').to.be.false + }) + }) + + describe('rotateDeviceId', () => { + it('returns false and does NOT write when no config file exists', async () => { + store.read.resolves() + + const rotated = await handler.rotateDeviceId() + + expect(rotated).to.be.false + expect(store.write.called, 'must not seed a config just to rotate').to.be.false + }) + + it('writes a new deviceId, preserves analytics flag + version, and returns true', async () => { + const before = GlobalConfig.create('device-old').withAnalytics(true) + store.read.resolves(before) + + const rotated = await handler.rotateDeviceId() + + expect(rotated).to.be.true + expect(store.write.calledOnce).to.be.true + const written = store.write.firstCall.args[0] + expect(written.deviceId).to.not.equal('device-old') + // Pin UUID v4 shape so a regression that swaps in a non-UUID source + // (e.g. Date.now().toString()) fails loudly at the test boundary. + expect(written.deviceId, 'rotated to a UUID v4').to.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ) + expect(written.analytics, 'analytics flag preserved').to.equal(before.analytics) + expect(written.version, 'version preserved').to.equal(before.version) + }) + + it('serializes concurrent rotate + setAnalytics through writeChain', async () => { + // Pre-existing config so neither call hits the idempotent no-op path. + const before = GlobalConfig.create('device-A').withAnalytics(false) + store.read.resolves(before) + + const writeOrder: string[] = [] + let resolveFirst!: () => void + const firstWriteGate = new Promise((resolve) => { + resolveFirst = resolve + }) + + // First write call (rotation) gates on firstWriteGate so we can + // observe whether the second call (setAnalytics) waits for it. + // Label by call ordinal (operation identity), NOT by `cfg.analytics` + // — the seed could change in the future and a content-based label + // would silently mislabel. + store.write.callsFake(async (_cfg: GlobalConfig) => { + const ordinal = store.write.callCount + if (ordinal === 1) { + await firstWriteGate + } + + writeOrder.push(ordinal === 1 ? 'rotate' : 'setAnalytics') + store.read.resolves(_cfg) + }) + + const rotatePromise = handler.rotateDeviceId() + const setPromise = (async () => { + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + return fn({analytics: true}, 'client-1') + })() + + // Give the event loop a tick so both calls enter the chain. + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(writeOrder, 'second write must NOT have started while first is gated').to.have.lengthOf(0) + resolveFirst() + + await Promise.all([rotatePromise, setPromise]) + + expect(writeOrder).to.deep.equal(['rotate', 'setAnalytics']) + }) + + it('does NOT mutate cachedAnalytics', async () => { + const before = GlobalConfig.create('device-1').withAnalytics(true) + store.read.resolves(before) + await handler.refreshCache() + expect(handler.getCachedAnalytics(), 'cache starts true').to.be.true + + await handler.rotateDeviceId() + + expect(handler.getCachedAnalytics(), 'rotation must leave the cached flag untouched').to.be.true + }) + + it('does NOT emit any analytics event', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const before = GlobalConfig.create('device-old').withAnalytics(true) + store.read.resolves(before) + + await handlerWithClient.rotateDeviceId() + + expect(analyticsClient.track.called, 'rotation is implicit — no analytics event fires').to.be.false + }) + }) + + describe('analytics_disabled emit', () => { + it('emits analytics_disabled exactly once on enable→disable transition', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + const trackCalls = analyticsClient.track + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.ANALYTICS_DISABLED) + expect(trackCalls.length, 'analytics_disabled fires exactly once on disable transition').to.equal(1) + }) + + it('emits BEFORE cachedAnalytics flips (so isEnabled reads true at track time)', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + await handlerWithClient.refreshCache() + expect(handlerWithClient.getCachedAnalytics(), 'cache starts true after refresh').to.be.true + + // Capture the value of cachedAnalytics at the moment track() is called. + let cacheAtTrack: boolean | undefined + analyticsClient.track.callsFake(() => { + cacheAtTrack = handlerWithClient.getCachedAnalytics() + }) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + expect(cacheAtTrack, 'cache still reports true at the moment track fires').to.equal(true) + expect(handlerWithClient.getCachedAnalytics(), 'cache flips to false after the call returns').to.equal(false) + }) + + it('does NOT emit on idempotent disable (false → false)', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + store.read.resolves() // no config = previous false + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + const trackCalls = analyticsClient.track + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.ANALYTICS_DISABLED) + expect(trackCalls.length, 'no transition = no emit').to.equal(0) + }) + + it('does NOT emit on enable (false → true) — analytics_enabled is intentionally not tracked', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const disabled = GlobalConfig.create('device-x').withAnalytics(false) + store.read.resolves(disabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: true}, 'client-1') + + const trackCalls = analyticsClient.track + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.ANALYTICS_DISABLED) + expect(trackCalls.length, 'enable must never produce analytics_disabled').to.equal(0) + }) + + it('does not crash the SET when track throws', async () => { + const analyticsClient = makeTrackingClient() + analyticsClient.track.throws(new Error('boom')) + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + const response = await fn({analytics: false}, 'client-1') + + expect(response.current, 'disable completes even when track throws').to.be.false + expect(response.previous).to.be.true + }) + }) +}) diff --git a/test/unit/infra/transport/handlers/hub-handler.test.ts b/test/unit/infra/transport/handlers/hub-handler.test.ts new file mode 100644 index 000000000..2e221ae58 --- /dev/null +++ b/test/unit/infra/transport/handlers/hub-handler.test.ts @@ -0,0 +1,229 @@ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {HubEntryDTO} from '../../../../../src/shared/transport/types/dto.js' + +import {HubHandler} from '../../../../../src/server/infra/transport/handlers/hub-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {HubEvents} from '../../../../../src/shared/transport/events/hub-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +function buildEntry(overrides: Partial = {}): HubEntryDTO { + return { + description: 'test entry', + files: [], + id: 'team/pkg', + name: 'pkg', + registry: 'official', + type: 'skill', + ...overrides, + } as HubEntryDTO +} + +describe('HubHandler analytics emits', () => { + let transport: MockTransportServer + + beforeEach(() => { + transport = createMockTransportServer() + }) + + afterEach(() => { + restore() + }) + + type InstallOutcome = 'success' | 'throw' + async function createHandler(opts: { + analyticsClient?: IAnalyticsClient + entries?: HubEntryDTO[] + installOutcome?: InstallOutcome + registryAddOutcome?: 'success' | 'throw_validate' | 'throw_write' + registryRemoveOutcome?: 'success' | 'throw' + }): Promise<{handler: HubHandler}> { + const installFn = + opts.installOutcome === 'throw' + ? stub().rejects(new Error('install boom')) + : stub().resolves({installedFiles: [], installedPath: '/p', message: 'ok'}) + + const registries = [ + {authScheme: 'none' as const, name: 'private', url: 'https://example.com'}, + ] + const removeStub = + opts.registryRemoveOutcome === 'throw' + ? stub().rejects(new Error('rm boom')) + : stub().resolves() + + const hubRegistryConfigStore = { + addRegistry: + opts.registryAddOutcome === 'throw_write' + ? stub().rejects(new Error('write boom')) + : stub().resolves(), + getRegistries: stub().resolves(registries), + removeRegistry: removeStub, + } + const hubKeychainStore = {deleteToken: stub().resolves(), getToken: stub().resolves(), setToken: stub().resolves()} + const hubInstallService = {install: installFn} + + const handler = new HubHandler({ + analyticsClient: opts.analyticsClient, + hubInstallService: hubInstallService as never, + hubKeychainStore: hubKeychainStore as never, + hubRegistryConfigStore: hubRegistryConfigStore as never, + officialRegistryUrl: 'https://hub.example.com', + resolveProjectPath: stub().returns('/proj') as never, + transport, + }) + + // Stub the dynamic registry service before setup() instead of relying on + // the real composite service network calls. + const entries = opts.entries ?? [buildEntry()] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(handler as any).hubRegistryService = { + getEntries: stub().resolves({entries, version: '1'}), + getEntriesById: stub().resolves(entries), + } + // Suppress rebuildRegistryService' real path + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(handler as any).rebuildRegistryService = stub().resolves() + await handler.setup() + return {handler} + } + + async function callInstall(data: Record): Promise { + const h = transport._handlers.get(HubEvents.INSTALL) + expect(h, 'hub:install handler should be registered').to.exist + return h!(data, 'client-1') + } + + async function callRegistryAdd(data: Record): Promise { + const h = transport._handlers.get(HubEvents.REGISTRY_ADD) + expect(h, 'hub:registryAdd handler should be registered').to.exist + return h!(data, 'client-1') + } + + async function callRegistryRemove(data: Record): Promise { + const h = transport._handlers.get(HubEvents.REGISTRY_REMOVE) + expect(h, 'hub:registryRemove handler should be registered').to.exist + return h!(data, 'client-1') + } + + describe('hub_package_installed', () => { + it('emits outcome=success when install succeeds', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient}) + + await callInstall({entryId: 'team/pkg'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_PACKAGE_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; package_identifier: string} + expect(props.outcome).to.equal('success') + expect(props.package_identifier).to.equal('team/pkg') + }) + + it('emits outcome=failure with failure_kind=resolve when entry not found', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient, entries: []}) + + await callInstall({entryId: 'team/missing'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_PACKAGE_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('resolve') + }) + + it('emits outcome=failure with failure_kind=install_failed when install throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient, installOutcome: 'throw'}) + + await callInstall({entryId: 'team/pkg'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_PACKAGE_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('install_failed') + }) + }) + + describe('hub_registry_added', () => { + it('emits outcome=failure with failure_kind=validation on reserved name', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient}) + + const result = await callRegistryAdd({name: 'official', url: 'https://x'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_REGISTRY_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string; registry_kind: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('validation') + expect(props.registry_kind).to.equal('official') + }) + }) + + describe('hub_registry_removed', () => { + it('emits outcome=success when remove succeeds', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient}) + + const result = await callRegistryRemove({name: 'private'}) + expect(result).to.deep.include({success: true}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_REGISTRY_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; registry_kind: string} + expect(props.outcome).to.equal('success') + expect(props.registry_kind).to.equal('private') + }) + + it('emits outcome=failure with failure_kind=config_write when remove throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient, registryRemoveOutcome: 'throw'}) + + const result = await callRegistryRemove({name: 'private'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_REGISTRY_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('config_write') + }) + }) + + it('is a no-op when no analyticsClient is injected (backward-compat)', async () => { + await createHandler({}) + + const result = await callInstall({entryId: 'team/pkg'}) + expect(result).to.deep.include({success: true}) + }) +}) diff --git a/test/unit/infra/transport/handlers/init-handler.test.ts b/test/unit/infra/transport/handlers/init-handler.test.ts index 56b8b42da..21ab09c18 100644 --- a/test/unit/infra/transport/handlers/init-handler.test.ts +++ b/test/unit/infra/transport/handlers/init-handler.test.ts @@ -3,11 +3,28 @@ import type {SinonStub} from 'sinon' import {expect} from 'chai' import {restore, stub} from 'sinon' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + import {GitVcInitializedError} from '../../../../../src/server/core/domain/errors/task-error.js' import {InitHandler} from '../../../../../src/server/infra/transport/handlers/init-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {InitEvents} from '../../../../../src/shared/transport/events/init-events.js' import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + // ==================== Tests ==================== describe('InitHandler', () => { @@ -46,8 +63,9 @@ describe('InitHandler', () => { restore() }) - function createHandler(): void { + function createHandler(overrides: {analyticsClient?: IAnalyticsClient} = {}): void { const handler = new InitHandler({ + analyticsClient: overrides.analyticsClient, broadcastToProject: stub() as never, cogitPullService: {pull: stub()} as never, connectorManagerFactory: stub() as never, @@ -139,4 +157,72 @@ describe('InitHandler', () => { expect(result).to.have.property('success', true) }) }) + + describe('brv_init analytics emits', () => { + it('emits brv_init outcome=success on local init success with had_existing_brv_dir=false', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(false) + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + await callLocalInitHandler() + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.BRV_INIT) + expect(calls.length, 'brv_init fires exactly once on local init success').to.equal(1) + const props = calls[0].args[1] as {had_existing_brv_dir: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_existing_brv_dir).to.equal(false) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits brv_init outcome=success with had_existing_brv_dir=true when already initialized', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(true) + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const result = await callLocalInitHandler() + expect(result).to.have.property('alreadyInitialized', true) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.BRV_INIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_existing_brv_dir: boolean; outcome: string} + expect(props.outcome).to.equal('success') + expect(props.had_existing_brv_dir).to.equal(true) + }) + + it('does NOT emit brv_init on auth-missing execute (no handler-level catch — error propagates uncaught)', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(false) + tokenStore.load.resolves() // returns undefined → NotAuthenticatedError + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + let threw = false + try { + await callExecuteHandler() + } catch { + threw = true + } + + expect(threw, 'execute should throw the original error').to.equal(true) + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.BRV_INIT) + expect(calls.length, 'no emit on failure paths without an existing catch').to.equal(0) + }) + + it('is a no-op when no analyticsClient is injected (backward-compat)', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(false) + createHandler() // no analyticsClient + + const result = await callLocalInitHandler() + expect(result).to.have.property('success', true) + }) + }) }) diff --git a/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts b/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts new file mode 100644 index 000000000..c26a2101c --- /dev/null +++ b/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts @@ -0,0 +1,232 @@ +import {expect} from 'chai' +import {mkdirSync, mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {AnalyticsEventName} from '../../../../../src/shared/analytics/event-names.js' +import type {PropsArg} from '../../../../../src/shared/analytics/events/index.js' +import type {MigrateRunProps} from '../../../../../src/shared/analytics/events/migrate-run.js' +import type {MigrateRollbackResponse, MigrateRunResponse} from '../../../../../src/shared/transport/events/migrate-events.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import { + ARCHIVE_FOLDER_PREFIX, + BRV_DIR, + CONTEXT_TREE_DIR, + MIGRATIONS_DIR, +} from '../../../../../src/server/infra/migrate/constants.js' +import {MigrateHandler} from '../../../../../src/server/infra/transport/handlers/migrate-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {MigrateEvents} from '../../../../../src/shared/transport/events/migrate-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +type TrackCall = {event: AnalyticsEventName; properties: unknown} + +type MockAnalyticsClient = IAnalyticsClient & { + readonly trackCalls: readonly TrackCall[] + trackThrows?: Error +} + +/** + * Hand-rolled mock preserving `track(event, ...rest: PropsArg)` generics. + * Mirrors the pattern from `analytics-handler.test.ts` so sinon's collapsed + * SinonStub overload doesn't fight the IAnalyticsClient contract. + */ +function makeMockAnalyticsClient(): MockAnalyticsClient { + const trackCalls: TrackCall[] = [] + const mock: MockAnalyticsClient = { + abort(): void { + /* not exercised */ + }, + flush: () => Promise.resolve(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: () => Promise.resolve(), + track(event: E, ...rest: PropsArg): void { + if (mock.trackThrows) throw mock.trackThrows + const [properties] = rest + trackCalls.push({event, properties}) + }, + trackCalls, + } + return mock +} + +function todayUtc(): string { + return new Date().toISOString().slice(0, 10) +} + +function isMigrateRunProps(value: unknown): value is MigrateRunProps { + return typeof value === 'object' && value !== null && 'mode' in value && 'outcome' in value +} + +function findMigrateRunEmits(client: MockAnalyticsClient): MigrateRunProps[] { + const out: MigrateRunProps[] = [] + for (const call of client.trackCalls) { + if (call.event !== AnalyticsEventNames.MIGRATE_RUN) continue + if (!isMigrateRunProps(call.properties)) continue + out.push(call.properties) + } + + return out +} + +describe('MigrateHandler analytics emits', () => { + let transport: MockTransportServer + let analyticsClient: MockAnalyticsClient + let projectRoot: string + + beforeEach(() => { + transport = createMockTransportServer() + analyticsClient = makeMockAnalyticsClient() + projectRoot = mkdtempSync(join(tmpdir(), 'brv-migrate-handler-analytics-')) + new MigrateHandler({ + analyticsClient, + resolveProjectPath: () => projectRoot, + transport, + }).setup() + }) + + afterEach(() => { + rmSync(projectRoot, {force: true, recursive: true}) + }) + + describe('forward path (migrate:run)', () => { + it('emits migrate_run outcome=success with forward counters on a clean project', async () => { + const handler = transport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + await handler({dryRun: true}, 'client-1') + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'forward') throw new Error(`expected forward, got ${props.mode}`) + expect(props.outcome).to.equal('success') + expect(props.dry_run).to.equal(true) + expect(props.migrated).to.equal(0) + expect(props.archived).to.equal(0) + expect(props.skipped).to.equal(0) + expect(props.failed).to.equal(0) + }) + + it('emits migrate_run outcome=failure with failure_kind when orchestrator throws (archive already exists)', async () => { + mkdirSync(join(projectRoot, BRV_DIR, CONTEXT_TREE_DIR), {recursive: true}) + mkdirSync( + join(projectRoot, BRV_DIR, MIGRATIONS_DIR, `${ARCHIVE_FOLDER_PREFIX}${todayUtc()}`), + {recursive: true}, + ) + + const handler = transport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + let caught: unknown + try { + await handler({dryRun: false}, 'client-1') + } catch (error) { + caught = error + } + + expect(caught, 'orchestrator throw must propagate').to.be.instanceOf(Error) + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'forward') throw new Error(`expected forward, got ${props.mode}`) + expect(props.outcome).to.equal('failure') + expect(props.dry_run).to.equal(false) + // Pin the exact classification, not just "a non-empty string": the + // orchestrator throws `Migration already ran today; ...` here, which + // `classifyMigrateFailure` prefix-matches to `archive_exists`. If that + // sentinel message is ever reworded the classifier degrades to + // `unknown` — this assertion fails the test instead of silently + // dropping `archive_exists` from the warehouse. + expect(props.failure_kind).to.equal('archive_exists') + }) + }) + + describe('rollback path (migrate:rollback)', () => { + it('emits migrate_run outcome=success with rollback counters when an archive exists', async () => { + mkdirSync( + join(projectRoot, BRV_DIR, MIGRATIONS_DIR, `${ARCHIVE_FOLDER_PREFIX}${todayUtc()}`), + {recursive: true}, + ) + + const handler = transport._handlers.get(MigrateEvents.ROLLBACK) + if (handler === undefined) throw new Error('migrate:rollback handler not registered') + + await handler({dryRun: true}, 'client-1') + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'rollback') throw new Error(`expected rollback, got ${props.mode}`) + expect(props.outcome).to.equal('success') + expect(props.dry_run).to.equal(true) + expect(props.restored).to.equal(0) + expect(props.deleted_html).to.equal(0) + expect(props.preserved_html).to.equal(0) + }) + + it('emits migrate_run outcome=failure with failure_kind when no archive exists', async () => { + const handler = transport._handlers.get(MigrateEvents.ROLLBACK) + if (handler === undefined) throw new Error('migrate:rollback handler not registered') + + let caught: unknown + try { + await handler({dryRun: true}, 'client-1') + } catch (error) { + caught = error + } + + expect(caught, 'orchestrator throw must propagate').to.be.instanceOf(Error) + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'rollback') throw new Error(`expected rollback, got ${props.mode}`) + expect(props.outcome).to.equal('failure') + expect(props.dry_run).to.equal(true) + // Pin the exact classification: the orchestrator throws + // `No archive to roll back. ...` here, which `classifyMigrateFailure` + // prefix-matches to `no_archive`. Asserting the value (not just + // "non-empty string") turns a future sentinel-message reword into a + // failing test rather than a silent `unknown` in the warehouse. + expect(props.failure_kind).to.equal('no_archive') + }) + }) + + describe('no-op when analyticsClient is not injected', () => { + it('does not throw and does not call track on either path', async () => { + const localTransport = createMockTransportServer() + const localAnalyticsClient = makeMockAnalyticsClient() + new MigrateHandler({ + resolveProjectPath: () => projectRoot, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + await handler({dryRun: true}, 'client-1') + expect(localAnalyticsClient.trackCalls).to.have.lengthOf(0) + }) + }) + + describe('analytics throw never propagates to caller', () => { + it('forward path returns a normal report even if track() throws', async () => { + analyticsClient.trackThrows = new Error('analytics down') + + const handler = transport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + const response: MigrateRollbackResponse | MigrateRunResponse = await handler( + {dryRun: true}, + 'client-1', + ) + + if (!('report' in response)) throw new Error('expected a forward report on success') + expect(response.report.summary).to.exist + }) + }) +}) diff --git a/test/unit/infra/transport/handlers/reset-handler-analytics.test.ts b/test/unit/infra/transport/handlers/reset-handler-analytics.test.ts new file mode 100644 index 000000000..774e68d05 --- /dev/null +++ b/test/unit/infra/transport/handlers/reset-handler-analytics.test.ts @@ -0,0 +1,136 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' +import type {IContextTreeSnapshotService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-snapshot-service.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {ContextTreeNotInitializedError} from '../../../../../src/server/core/domain/errors/task-error.js' +import {ResetHandler} from '../../../../../src/server/infra/transport/handlers/reset-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ResetEvents} from '../../../../../src/shared/transport/events/reset-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ResetHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let contextTreeService: Stubbed + let contextTreeSnapshotService: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + contextTreeService = { + delete: sandbox.stub().resolves(), + exists: sandbox.stub().resolves(true), + hasGitRepo: sandbox.stub().resolves(false), + initialize: sandbox.stub().resolves('/proj/.brv/context-tree'), + resolvePath: sandbox.stub().returns('/proj/.brv/context-tree'), + } + contextTreeSnapshotService = { + getChanges: sandbox.stub(), + getCurrentState: sandbox.stub(), + getSnapshotState: sandbox.stub(), + hasSnapshot: sandbox.stub(), + initEmptySnapshot: sandbox.stub().resolves(), + saveSnapshot: sandbox.stub(), + saveSnapshotFromState: sandbox.stub(), + } + analyticsClient = makeFakeAnalyticsClient() + new ResetHandler({ + analyticsClient, + contextTreeService, + contextTreeSnapshotService, + curateLogStoreFactory: () => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(), + list: sandbox.stub().resolves([]), + }) as never, + resolveProjectPath: sandbox.stub().returns('/proj') as never, + reviewBackupStoreFactory: () => ({clear: sandbox.stub().resolves()}) as never, + transport, + }).setup() + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits daemon_reset_executed outcome=success with reset_scope=project', async () => { + await requestHandlers[ResetEvents.EXECUTE](undefined, 'c1') + const calls = emits(AnalyticsEventNames.DAEMON_RESET_EXECUTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; reset_scope: string} + expect(props.outcome).to.equal('success') + expect(props.reset_scope).to.equal('project') + }) + + it('emits daemon_reset_executed outcome=failure failure_kind=not_initialized when context tree absent', async () => { + contextTreeService.exists.resolves(false) + try { + await requestHandlers[ResetEvents.EXECUTE](undefined, 'c1') + expect.fail('should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(ContextTreeNotInitializedError) + } + + const calls = emits(AnalyticsEventNames.DAEMON_RESET_EXECUTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('not_initialized') + }) + + it('is a no-op when analyticsClient is not injected', async () => { + const local: Record = {} + const tLocal = {...transport, onRequest: sandbox.stub().callsFake((e: string, h: RequestHandler) => { + local[e] = h + })} as never + new ResetHandler({ + contextTreeService, + contextTreeSnapshotService, + curateLogStoreFactory: () => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(), + list: sandbox.stub().resolves([]), + }) as never, + resolveProjectPath: sandbox.stub().returns('/proj') as never, + reviewBackupStoreFactory: () => ({clear: sandbox.stub().resolves()}) as never, + transport: tLocal, + }).setup() + await local[ResetEvents.EXECUTE](undefined, 'c1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/review-handler-analytics.test.ts b/test/unit/infra/transport/handlers/review-handler-analytics.test.ts new file mode 100644 index 000000000..45dc4a9c2 --- /dev/null +++ b/test/unit/infra/transport/handlers/review-handler-analytics.test.ts @@ -0,0 +1,211 @@ +import {expect} from 'chai' +import {mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {BRV_CONFIG_VERSION} from '../../../../../src/server/constants.js' +import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-config.js' +import {ReviewHandler} from '../../../../../src/server/infra/transport/handlers/review-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ReviewEvents} from '../../../../../src/shared/transport/events/review-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +function makeConfig(): BrvConfig { + return new BrvConfig({ + createdAt: '2024-01-01T00:00:00.000Z', + cwd: '/proj', + version: BRV_CONFIG_VERSION, + }) +} + +describe('ReviewHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let projectConfigStore: {exists: SinonStub; getModifiedTime: SinonStub; read: SinonStub; write: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + projectDir = mkdtempSync(join(tmpdir(), 'brv-review-proj-')) + + projectConfigStore = { + exists: sandbox.stub().resolves(true), + getModifiedTime: sandbox.stub().resolves(), + read: sandbox.stub().resolves(makeConfig()), + write: sandbox.stub().resolves(), + } + + analyticsClient = makeFakeAnalyticsClient() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + }) + + function makeHandler(opts: {curateLog?: never[]; injectClient?: boolean} = {}): void { + const entries = opts.curateLog ?? [] + new ReviewHandler({ + analyticsClient: opts.injectClient === false ? undefined : analyticsClient, + curateLogStoreFactory: () => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(), + list: sandbox.stub().resolves(entries), + }) as never, + projectConfigStore: projectConfigStore as never, + resolveProjectPath: sandbox.stub().returns(projectDir) as never, + reviewBackupStoreFactory: () => ({ + clear: sandbox.stub().resolves(), + delete: sandbox.stub().resolves(), + read: sandbox.stub().resolves(null), + }) as never, + transport, + }).setup() + } + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits review_toggled outcome=success with new_state=disabled on disable', async () => { + makeHandler() + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: true}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_TOGGLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {new_state?: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.new_state).to.equal('disabled') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits review_toggled new_state=enabled on enable', async () => { + makeHandler() + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: false}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_TOGGLED) + const props = calls[0].args[1] as {new_state?: string} + expect(props.new_state).to.equal('enabled') + }) + + it('emits review_toggled outcome=failure failure_kind=config_write on write failure', async () => { + projectConfigStore.write.rejects(new Error('disk full')) + makeHandler() + try { + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: true}, 'c1') + expect.fail('should throw') + } catch { + // expected + } + + const calls = emits(AnalyticsEventNames.REVIEW_TOGGLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('config_write') + }) + + it('emits review_approved per file with operation_kind on approve', async () => { + const contextTreeDir = join(projectDir, '.brv', 'context-tree') + const fakeEntry = { + id: 'log-1', + operations: [ + { + filePath: join(contextTreeDir, 'topic-a.md'), + path: 'topic-a', + reviewStatus: 'pending', + status: 'completed', + type: 'ADD', + }, + ], + startedAt: 1, + status: 'completed', + taskId: 't1', + } + makeHandler({curateLog: [fakeEntry] as never}) + await requestHandlers[ReviewEvents.DECIDE_TASK]({decision: 'approved', taskId: 't1'}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_APPROVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {operation_kind: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.operation_kind).to.equal('add') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits review_rejected per file with operation_kind on reject', async () => { + const contextTreeDir = join(projectDir, '.brv', 'context-tree') + const fakeEntry = { + id: 'log-1', + operations: [ + { + filePath: join(contextTreeDir, 'topic-a.md'), + path: 'topic-a', + reviewStatus: 'pending', + status: 'completed', + type: 'DELETE', + }, + ], + startedAt: 1, + status: 'completed', + taskId: 't1', + } + makeHandler({curateLog: [fakeEntry] as never}) + await requestHandlers[ReviewEvents.DECIDE_TASK]({decision: 'rejected', taskId: 't1'}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_REJECTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {operation_kind: string; outcome: string} + expect(props.operation_kind).to.equal('delete') + }) + + it('emits review_approved outcome=failure failure_kind=not_found when taskId has no pending ops', async () => { + makeHandler({curateLog: [] as never}) + await requestHandlers[ReviewEvents.DECIDE_TASK]({decision: 'approved', taskId: 'nope'}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_APPROVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('not_found') + }) + + it('is a no-op when analyticsClient is not injected', async () => { + makeHandler({injectClient: false}) + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: true}, 'c1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts b/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts new file mode 100644 index 000000000..c6af82141 --- /dev/null +++ b/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts @@ -0,0 +1,219 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {SettingDescriptor} from '../../../../../src/server/core/domain/entities/settings.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ISettingsStore} from '../../../../../src/server/core/interfaces/storage/i-settings-store.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {SETTINGS_KEYS} from '../../../../../src/server/core/domain/entities/settings.js' +import {InvalidSettingValueError, UnknownSettingKeyError} from '../../../../../src/server/infra/storage/settings-validator.js' +import {SettingsHandler} from '../../../../../src/server/infra/transport/handlers/settings-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {SettingsEvents} from '../../../../../src/shared/transport/events/settings-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('SettingsHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let store: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + store = { + get: sandbox.stub(), + list: sandbox.stub().resolves([]), + readStartupSnapshot: sandbox.stub().resolves({}), + reset: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + } + analyticsClient = makeFakeAnalyticsClient() + new SettingsHandler({analyticsClient, store, transport}).setup() + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits setting_changed outcome=success with value_kind + value_changed_from_default', async () => { + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 42}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + outcome: string + setting_key: string + value_changed_from_default?: boolean + value_kind: string + } + expect(props.outcome).to.equal('success') + expect(props.setting_key).to.equal(SETTINGS_KEYS.AGENT_POOL_MAX_SIZE) + expect(props.value_kind).to.equal('integer') + expect(props.value_changed_from_default).to.equal(true) + }) + + it('emits setting_changed outcome=failure failure_kind=unknown_key on UnknownSettingKeyError', async () => { + store.set.rejects(new UnknownSettingKeyError('bogus.key')) + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: 'bogus.key', value: 1}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; setting_key: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('unknown_key') + expect(props.setting_key).to.equal('bogus.key') + }) + + it('emits setting_changed outcome=failure failure_kind=validation on InvalidSettingValueError', async () => { + store.set.rejects(new InvalidSettingValueError(SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, 9999, 'too big')) + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 9999}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('validation') + }) + + it('emits setting_reset outcome=success', async () => { + const handler = requestHandlers[SettingsEvents.RESET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_RESET) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; setting_key: string; value_kind: string} + expect(props.outcome).to.equal('success') + expect(props.value_kind).to.equal('integer') + }) + + it('emits setting_reset outcome=failure failure_kind=unknown_key on UnknownSettingKeyError', async () => { + store.reset.rejects(new UnknownSettingKeyError('bogus.key')) + const handler = requestHandlers[SettingsEvents.RESET] + await handler({key: 'bogus.key'}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_RESET) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('unknown_key') + }) + + it('regression: setting_changed payload never includes raw value or message', async () => { + const secretValue = 'super-secret-string-leak-check' as unknown as number + store.set.rejects(new Error('boom: super-secret-string-leak-check')) + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: secretValue}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + const props = calls[0].args[1] as Record + const json = JSON.stringify(props) + expect(json).to.not.include('super-secret-string-leak-check') + expect(json).to.not.include('boom:') + }) + + it('is a no-op when analyticsClient is not injected', async () => { + const local: Record = {} + const transportLocal = {...transport, onRequest: sandbox.stub().callsFake((e: string, h: RequestHandler) => { + local[e] = h + })} as never + new SettingsHandler({store, transport: transportLocal}).setup() + await local[SettingsEvents.SET]({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 1}, 'c1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + ] + + let isolatedRequestHandlers: Record + let isolatedAnalytics: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + isolatedRequestHandlers = {} + const isolatedTransport = { + ...transport, + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + isolatedRequestHandlers[event] = handler + }), + } as never + isolatedAnalytics = makeFakeAnalyticsClient() + new SettingsHandler({ + analyticsClient: isolatedAnalytics, + registry: readonlyInfoRegistry, + store, + transport: isolatedTransport, + }).setup() + }) + + it('emits setting_changed failure_kind=read_only with value_kind=readonly-info on SET attempt', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.SET] + await handler({key: '_test.snapshot', value: 1}, 'c1') + const calls = isolatedAnalytics.trackSpy.getCalls().filter((c) => c.args[0] === AnalyticsEventNames.SETTING_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; setting_key: string; value_kind: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('read_only') + expect(props.setting_key).to.equal('_test.snapshot') + expect(props.value_kind).to.equal('readonly-info') + }) + + it('emits setting_reset failure_kind=read_only with value_kind=readonly-info on RESET attempt', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.RESET] + await handler({key: '_test.snapshot'}, 'c1') + const calls = isolatedAnalytics.trackSpy.getCalls().filter((c) => c.args[0] === AnalyticsEventNames.SETTING_RESET) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; setting_key: string; value_kind: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('read_only') + expect(props.value_kind).to.equal('readonly-info') + }) + + it('does NOT call store.set when the SET is gated as read_only', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.SET] + await handler({key: '_test.snapshot', value: 1}, 'c1') + expect(store.set.called).to.equal(false) + }) + + it('does NOT call store.reset when the RESET is gated as read_only', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.RESET] + await handler({key: '_test.snapshot'}, 'c1') + expect(store.reset.called).to.equal(false) + }) + }) +}) diff --git a/test/unit/infra/transport/handlers/settings-handler.test.ts b/test/unit/infra/transport/handlers/settings-handler.test.ts index 8b4defc87..fb36733d9 100644 --- a/test/unit/infra/transport/handlers/settings-handler.test.ts +++ b/test/unit/infra/transport/handlers/settings-handler.test.ts @@ -1,6 +1,10 @@ import {expect} from 'chai' -import type {SettingItem} from '../../../../../src/server/core/domain/entities/settings.js' +import type { + SettingDescriptor, + SettingItem, +} from '../../../../../src/server/core/domain/entities/settings.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type { ISettingsStore, SettingsStartupSnapshot, @@ -17,9 +21,13 @@ import type { import { InvalidSettingValueError, + ReadonlySettingKeyError, UnknownSettingKeyError, } from '../../../../../src/server/infra/storage/settings-validator.js' -import {SettingsHandler} from '../../../../../src/server/infra/transport/handlers/settings-handler.js' +import { + type ReadonlyInfoProvider, + SettingsHandler, +} from '../../../../../src/server/infra/transport/handlers/settings-handler.js' import {SettingsEvents} from '../../../../../src/shared/transport/events/settings-events.js' import {createMockTransportServer} from '../../../../helpers/mock-factories.js' @@ -86,6 +94,8 @@ describe('SettingsHandler', () => { expect(result.items.map((i) => i.key).sort()).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', + 'analytics.share', + 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', 'taskHistory.maxEntries', @@ -327,4 +337,603 @@ describe('SettingsHandler', () => { if (!handler) throw new Error('RESET handler not registered') return handler(payload, 'test-client') as Promise } + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + ] + + let store: StubSettingsStore + let transport: ReturnType + + function setupHandler(opts: { + readonly providers?: ReadonlyMap + } = {}): void { + new SettingsHandler({ + infoProviders: opts.providers, + registry: readonlyInfoRegistry, + store, + transport, + }).setup() + } + + async function invokeList(): Promise { + const handler = transport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + return handler(undefined, 'test-client') as Promise + } + + async function invokeGet(payload: SettingsGetRequest): Promise { + const handler = transport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + return handler(payload, 'test-client') as Promise + } + + async function invokeSet(payload: SettingsSetRequest): Promise { + const handler = transport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + return handler(payload, 'test-client') as Promise + } + + async function invokeReset(payload: SettingsResetRequest): Promise { + const handler = transport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + return handler(payload, 'test-client') as Promise + } + + beforeEach(() => { + store = new StubSettingsStore() + store.listResult = [{current: undefined, key: '_test.snapshot', restartRequired: false}] + transport = createMockTransportServer() + }) + + describe('SET', () => { + it('returns code=read_only without calling store.set', async () => { + setupHandler() + const result = await invokeSet({key: '_test.snapshot', value: 1}) + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('_test.snapshot') + expect(result.error.message.toLowerCase()).to.include('read') + } + + const setCalls = store.calls.filter((c) => c.method === 'set') + expect(setCalls).to.have.lengthOf(0) + }) + + it('maps a ReadonlySettingKeyError thrown from the store to a read_only DTO error', async () => { + store.setBehavior = async (key) => { + throw new ReadonlySettingKeyError(key) + } + + setupHandler() + // Use a writable key that the registry knows about so we bypass the + // top-level guard. We simulate the store layer throwing for a key + // that escalated past pre-validation. + const writableRegistry: readonly SettingDescriptor[] = [ + { + category: 'concurrency', + default: 10, + description: 'test', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + const localTransport = createMockTransportServer() + const localStore = new StubSettingsStore() + localStore.setBehavior = async (key) => { + throw new ReadonlySettingKeyError(key) + } + + new SettingsHandler({registry: writableRegistry, store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: '_test.writable', value: 1}, 'test-client')) as SettingsSetResponse + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('_test.writable') + } + }) + }) + + describe('RESET', () => { + it('returns code=read_only without calling store.reset', async () => { + setupHandler() + const result = await invokeReset({key: '_test.snapshot'}) + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('_test.snapshot') + } + + const resetCalls = store.calls.filter((c) => c.method === 'reset') + expect(resetCalls).to.have.lengthOf(0) + }) + }) + + describe('LIST', () => { + it('resolves current via the registered info provider when present', async () => { + const providers = new Map([ + ['_test.snapshot', () => ({endpoint: 'test', queueDepth: 3})], + ]) + setupHandler({providers}) + + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot, 'readonly-info row must be present').to.exist + expect(snapshot?.type).to.equal('readonly-info') + expect(snapshot?.current).to.deep.equal({endpoint: 'test', queueDepth: 3}) + expect(snapshot?.default).to.equal(undefined) + expect(snapshot?.min).to.equal(undefined) + expect(snapshot?.max).to.equal(undefined) + expect(snapshot?.unit).to.equal(undefined) + }) + + it('returns current=undefined when no info provider is registered', async () => { + setupHandler() + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot?.current).to.equal(undefined) + }) + + it('awaits an async info provider before responding', async () => { + const providers = new Map([ + ['_test.snapshot', async () => ({lastFlush: 'now'})], + ]) + setupHandler({providers}) + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot?.current).to.deep.equal({lastFlush: 'now'}) + }) + + it('isolates a throwing provider so the row surfaces with current=undefined instead of crashing the whole list', async () => { + const providers = new Map([ + ['_test.snapshot', () => { + throw new Error('provider boom') + }], + ]) + setupHandler({providers}) + + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot, 'readonly-info row must still be present').to.exist + expect(snapshot?.current).to.equal(undefined) + }) + + it('isolates a single throwing provider while resolving other readonly-info rows in the same response', async () => { + const multiRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'broken snapshot', + key: '_test.broken', + restartRequired: false, + type: 'readonly-info', + }, + { + category: 'updates', + description: 'healthy snapshot', + key: '_test.healthy', + restartRequired: false, + type: 'readonly-info', + }, + ] + + const providers = new Map([ + ['_test.broken', () => { + throw new Error('provider boom') + }], + ['_test.healthy', () => ({queueDepth: 5})], + ]) + + const localStore = new StubSettingsStore() + localStore.listResult = [] + const localTransport = createMockTransportServer() + new SettingsHandler({ + infoProviders: providers, + registry: multiRegistry, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + const result = (await handler(undefined, 'test-client')) as SettingsListResponse + + const broken = result.items.find((i) => i.key === '_test.broken') + const healthy = result.items.find((i) => i.key === '_test.healthy') + expect(broken?.current).to.equal(undefined) + expect(healthy?.current).to.deep.equal({queueDepth: 5}) + }) + }) + + describe('GET', () => { + it('resolves current via the registered info provider when present', async () => { + const providers = new Map([ + ['_test.snapshot', () => ({queueDepth: 7})], + ]) + setupHandler({providers}) + + const result = await invokeGet({key: '_test.snapshot'}) + expect(result.ok).to.be.true + if (result.ok) { + expect(result.type).to.equal('readonly-info') + expect(result.current).to.deep.equal({queueDepth: 7}) + expect(result.default).to.equal(undefined) + } + }) + + it('returns current=undefined when no info provider is registered', async () => { + setupHandler() + const result = await invokeGet({key: '_test.snapshot'}) + expect(result.ok).to.be.true + if (result.ok) { + expect(result.current).to.equal(undefined) + } + }) + + it('returns invalid_value when the info provider throws (does not crash)', async () => { + const providers = new Map([ + ['_test.snapshot', () => { + throw new Error('provider boom') + }], + ]) + setupHandler({providers}) + + const result = await invokeGet({key: '_test.snapshot'}) + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('invalid_value') + expect(result.error.key).to.equal('_test.snapshot') + expect(result.error.message.toLowerCase()).to.include('boom') + } + }) + }) + }) + + describe('analytics.share facade routing (M16.2 — production registry)', () => { + type AnalyticsFacadeStub = { + readonly calls: Array<{args: unknown[]; method: string}> + currentValue: boolean + getCurrentAnalytics: () => Promise + setAnalyticsValue: (value: boolean) => Promise<{current: boolean; previous: boolean}> + } + + function makeFacade(initial: boolean): AnalyticsFacadeStub { + const stub: AnalyticsFacadeStub = { + calls: [], + currentValue: initial, + async getCurrentAnalytics() { + return stub.currentValue + }, + async setAnalyticsValue(value: boolean) { + stub.calls.push({args: [value], method: 'setAnalyticsValue'}) + const previous = stub.currentValue + stub.currentValue = value + return {current: value, previous} + }, + } + return stub + } + + it('GET on analytics.share reads from the injected globalConfigHandler (true)', async () => { + const facade = makeFacade(true) + const localStore = new StubSettingsStore() + localStore.listResult = [{current: undefined, key: 'analytics.share', restartRequired: false}] + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsGetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.type).to.equal('boolean') + expect(result.current).to.equal(true) + expect(result.default).to.equal(false) + expect(result.category).to.equal('analytics') + } + }) + + it('SET on analytics.share calls globalConfigHandler.setAnalyticsValue, NOT store.set', async () => { + const facade = makeFacade(false) + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: 'analytics.share', value: true}, 'test-client')) as SettingsSetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.restartRequired).to.equal(false) + } + + const setCalls = facade.calls.filter((c) => c.method === 'setAnalyticsValue') + expect(setCalls).to.have.lengthOf(1) + expect(setCalls[0].args).to.deep.equal([true]) + const storeSetCalls = localStore.calls.filter((c) => c.method === 'set') + expect(storeSetCalls, 'file store must not be touched').to.have.lengthOf(0) + }) + + it('RESET on analytics.share flips the globalConfig value to false, NOT store.reset', async () => { + const facade = makeFacade(true) + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.true + const setCalls = facade.calls.filter((c) => c.method === 'setAnalyticsValue') + expect(setCalls).to.have.lengthOf(1) + expect(setCalls[0].args).to.deep.equal([false]) + const storeResetCalls = localStore.calls.filter((c) => c.method === 'reset') + expect(storeResetCalls, 'file store must not be touched').to.have.lengthOf(0) + }) + + it('LIST surfaces analytics.share with the value from globalConfigHandler', async () => { + const facade = makeFacade(true) + const localStore = new StubSettingsStore() + localStore.listResult = [] + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + const result = (await handler(undefined, 'test-client')) as SettingsListResponse + + const row = result.items.find((i) => i.key === 'analytics.share') + expect(row, 'analytics.share row present').to.exist + expect(row?.type).to.equal('boolean') + expect(row?.current).to.equal(true) + expect(row?.default).to.equal(false) + }) + + it('SET on analytics.share emits SETTING_CHANGED with value_kind=boolean and outcome=success', async () => { + const facade = makeFacade(false) + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + const trackCalls: Array<{args: unknown[]}> = [] + const fakeClient = {track: (...args: unknown[]) => trackCalls.push({args})} as unknown as IAnalyticsClient + + new SettingsHandler({ + analyticsClient: fakeClient, + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + await handler({key: 'analytics.share', value: true}, 'test-client') + + const setting = trackCalls.find((c) => (c.args[0] as string).endsWith('setting_changed')) + expect(setting, 'SETTING_CHANGED emitted').to.exist + const props = setting!.args[1] as {outcome: string; value_kind: string} + expect(props.outcome).to.equal('success') + expect(props.value_kind).to.equal('boolean') + }) + + it('GET on analytics.share with NO injected facade returns current=undefined (graceful)', async () => { + const localStore = new StubSettingsStore() + localStore.listResult = [{current: undefined, key: 'analytics.share', restartRequired: false}] + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsGetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.current).to.equal(undefined) + } + }) + + it('SET on analytics.share with NO injected facade returns code=misconfigured (not invalid_value)', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: 'analytics.share', value: true}, 'test-client')) as SettingsSetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + // Bot review (#7): a missing facade is a daemon wiring problem, not a + // user-supplied bad value. Distinct code so logs / WebUI can route + // the alert at the right team. + expect(result.error.code).to.equal('misconfigured') + expect(result.error.key).to.equal('analytics.share') + expect(result.error.message.toLowerCase()).to.match(/global ?config|facade/) + } + }) + + it('RESET on analytics.share with NO injected facade returns code=misconfigured (not invalid_value)', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('misconfigured') + expect(result.error.key).to.equal('analytics.share') + } + }) + + it('RESET refuses non-boolean global-config descriptors with code=misconfigured (future-proofing for bot review #6)', async () => { + // The facade interface (`setAnalyticsValue(value: boolean)`) is + // structurally boolean-only. A future PR that adds an integer + // descriptor with `storage: 'global-config'` would otherwise hit + // the `: false` fallback in RESET and silently coerce to boolean. + // Tighten with a custom registry stub so this is enforced today. + const intGlobalDescriptor: SettingDescriptor = { + category: 'analytics', + default: 42, + description: 'fake integer global-config descriptor — test only', + key: '_test.integerGlobal', + max: 100, + min: 0, + restartRequired: false, + storage: 'global-config', + type: 'integer', + } + const facade = { + getCurrentAnalytics: async () => false, + setAnalyticsValue: async (value: boolean) => ({current: value, previous: false}), + } + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + registry: [intGlobalDescriptor], + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: '_test.integerGlobal'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('misconfigured') + expect(result.error.key).to.equal('_test.integerGlobal') + expect(result.error.message.toLowerCase()).to.match(/boolean|facade/) + } + }) + }) + + describe('analytics.status routing (M16.3 — production registry)', () => { + it('GET resolves analytics.status via the registered provider against the production registry', async () => { + const localStore = new StubSettingsStore() + // Real FileSettingsStore returns `{current: undefined, key, restartRequired: false}` + // for readonly-info keys. Stub mirrors that so the handler's GET path + // reaches the provider resolution step. + localStore.listResult = [{current: undefined, key: 'analytics.status', restartRequired: false}] + const localTransport = createMockTransportServer() + const snapshot = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy' as const}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: 1_700_000_000_000, + queueDepth: 4, + } + const providers = new Map([ + ['analytics.status', () => snapshot], + ]) + new SettingsHandler({infoProviders: providers, store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + const result = (await handler({key: 'analytics.status'}, 'test-client')) as SettingsGetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.type).to.equal('readonly-info') + expect(result.current).to.deep.equal(snapshot) + expect(result.category).to.equal('analytics') + expect(result.default).to.equal(undefined) + } + }) + + it('SET on analytics.status returns code=read_only against the production registry', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: 'analytics.status', value: 1}, 'test-client')) as SettingsSetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('analytics.status') + } + }) + + it('RESET on analytics.status returns code=read_only against the production registry', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: 'analytics.status'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('analytics.status') + } + }) + + it('LIST includes analytics.status as a readonly-info row with current resolved by the provider', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + const snapshot = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy' as const}, + droppedCount: 0, + enabled: false, + endpoint: 'https://telemetry-dev.byterover.dev', + queueDepth: 0, + } + const providers = new Map([ + ['analytics.status', () => snapshot], + ]) + new SettingsHandler({infoProviders: providers, store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + const result = (await handler(undefined, 'test-client')) as SettingsListResponse + + const row = result.items.find((i) => i.key === 'analytics.status') + expect(row, 'analytics.status row present in LIST').to.exist + expect(row?.type).to.equal('readonly-info') + expect(row?.category).to.equal('analytics') + expect(row?.current).to.deep.equal(snapshot) + expect(row?.default).to.equal(undefined) + }) + }) }) diff --git a/test/unit/infra/transport/handlers/source-handler.test.ts b/test/unit/infra/transport/handlers/source-handler.test.ts new file mode 100644 index 000000000..d473a3eb5 --- /dev/null +++ b/test/unit/infra/transport/handlers/source-handler.test.ts @@ -0,0 +1,129 @@ + +import {expect} from 'chai' +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {SourceHandler} from '../../../../../src/server/infra/transport/handlers/source-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {SourceEvents} from '../../../../../src/shared/transport/events/source-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('SourceHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let sourceDir: string + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + projectDir = mkdtempSync(join(tmpdir(), 'brv-src-proj-')) + mkdirSync(join(projectDir, '.brv'), {recursive: true}) + writeFileSync(join(projectDir, '.brv', 'config.json'), '{}') + sourceDir = mkdtempSync(join(tmpdir(), 'brv-src-target-')) + mkdirSync(join(sourceDir, '.brv'), {recursive: true}) + writeFileSync(join(sourceDir, '.brv', 'config.json'), '{}') + + analyticsClient = makeFakeAnalyticsClient() + new SourceHandler({ + analyticsClient, + resolveProjectPath: sandbox.stub().returns(projectDir) as never, + transport, + }).setup() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + rmSync(sourceDir, {force: true, recursive: true}) + }) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits source_added outcome=success with source_origin_hash on add success', async () => { + const handler = requestHandlers[SourceEvents.ADD] + await handler({targetPath: sourceDir}, 'client-1') + const calls = emits(AnalyticsEventNames.SOURCE_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + outcome: string + project_path_hash: string + source_origin_hash?: string + } + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + expect(props.source_origin_hash).to.match(sha256HexRegex) + }) + + it('emits source_added outcome=failure when target is not a BRV project', async () => { + const handler = requestHandlers[SourceEvents.ADD] + const notBrvDir = mkdtempSync(join(tmpdir(), 'brv-src-bad-')) + try { + await handler({targetPath: notBrvDir}, 'client-1') + const calls = emits(AnalyticsEventNames.SOURCE_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('add_failed') + } finally { + rmSync(notBrvDir, {force: true, recursive: true}) + } + }) + + it('emits source_removed outcome=failure on non-existent alias', async () => { + const handler = requestHandlers[SourceEvents.REMOVE] + await handler({aliasOrPath: 'nonexistent'}, 'client-1') + const calls = emits(AnalyticsEventNames.SOURCE_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('remove_failed') + }) + + it('does NOT emit on list', async () => { + const handler = requestHandlers[SourceEvents.LIST] + await handler({}, 'client-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/space-handler.test.ts b/test/unit/infra/transport/handlers/space-handler.test.ts index 39970f0de..6798b2b74 100644 --- a/test/unit/infra/transport/handlers/space-handler.test.ts +++ b/test/unit/infra/transport/handlers/space-handler.test.ts @@ -3,6 +3,7 @@ import type {SinonStubbedInstance} from 'sinon' import {expect} from 'chai' import {restore, stub} from 'sinon' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' import type {IContextTreeMerger} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-merger.js' import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' @@ -30,6 +31,7 @@ import { ProjectNotInitError, } from '../../../../../src/server/core/domain/errors/task-error.js' import {SpaceHandler} from '../../../../../src/server/infra/transport/handlers/space-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {PullEvents} from '../../../../../src/shared/transport/events/pull-events.js' import {SpaceEvents} from '../../../../../src/shared/transport/events/space-events.js' @@ -993,4 +995,99 @@ describe('SpaceHandler', () => { expect(result.teams).to.exist }) }) + + describe('space_switched analytics emits', () => { + let analyticsClient: IAnalyticsClient & {trackSpy: ReturnType} + + function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} + } + + function createHandlerWithAnalytics(): void { + const handler = new SpaceHandler({ + analyticsClient, + broadcastToProject, + cogitPullService, + contextTreeMerger, + contextTreeService: contextTreeService as unknown as IContextTreeService, + contextTreeSnapshotService, + contextTreeWriterService, + projectConfigStore, + resolveProjectPath, + spaceService, + teamService, + tokenStore, + transport, + }) + handler.setup() + } + + function emitsOf(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c: {args: unknown[]}) => c.args[0] === name) + } + + beforeEach(() => { + analyticsClient = makeFakeAnalyticsClient() + }) + + it('emits space_switched outcome=success on switch to a different space', async () => { + setupSwitchMocks() + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-2'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {from_space_id: string; outcome: string; to_space_id?: string} + expect(props.outcome).to.equal('success') + expect(props.from_space_id).to.equal('space-1') + expect(props.to_space_id).to.equal('space-2') + }) + + it('emits space_switched outcome=success on no-op (same space) when existing space is set', async () => { + setupSwitchMocks() + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-1'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {from_space_id: string; to_space_id?: string} + expect(props.from_space_id).to.equal('space-1') + expect(props.to_space_id).to.equal('space-1') + }) + + it('does NOT emit space_switched on first-time connect (no existing space)', async () => { + setupSwitchMocks(createLocalOnlyConfig()) + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-2'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(0) + }) + + it('emits space_switched outcome=failure when pull throws', async () => { + setupSwitchMocks() + cogitPullService.pull.rejects(new Error('network down')) + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-2'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; from_space_id: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('pull_failed') + expect(props.from_space_id).to.equal('space-1') + }) + }) }) diff --git a/test/unit/infra/transport/handlers/swarm-handler.test.ts b/test/unit/infra/transport/handlers/swarm-handler.test.ts new file mode 100644 index 000000000..0ad2c3736 --- /dev/null +++ b/test/unit/infra/transport/handlers/swarm-handler.test.ts @@ -0,0 +1,205 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {AnalyticsEventName} from '../../../../../src/shared/analytics/event-names.js' +import type {PropsArg} from '../../../../../src/shared/analytics/events/index.js' +import type {SwarmTrackResponse} from '../../../../../src/shared/transport/events/swarm-events.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {SwarmHandler} from '../../../../../src/server/infra/transport/handlers/swarm-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {SwarmEvents} from '../../../../../src/shared/transport/events/swarm-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +type TrackCall = {event: AnalyticsEventName; properties: unknown} + +type MockAnalyticsClient = IAnalyticsClient & { + readonly trackCalls: readonly TrackCall[] + trackThrows?: Error +} + +/** + * Hand-rolled mock preserving `track(event, ...rest: PropsArg)` generics. + * Mirrors the pattern from `migrate-handler-analytics.test.ts`. + */ +function makeMockAnalyticsClient(): MockAnalyticsClient { + const trackCalls: TrackCall[] = [] + const mock: MockAnalyticsClient = { + abort(): void { + /* not exercised */ + }, + flush: () => Promise.resolve(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: () => Promise.resolve(), + track(event: E, ...rest: PropsArg): void { + if (mock.trackThrows) throw mock.trackThrows + const [properties] = rest + trackCalls.push({event, properties}) + }, + trackCalls, + } + return mock +} + +describe('SwarmHandler', () => { + let transport: MockTransportServer + let analyticsClient: MockAnalyticsClient + + beforeEach(() => { + transport = createMockTransportServer() + analyticsClient = makeMockAnalyticsClient() + new SwarmHandler({analyticsClient, transport}).setup() + }) + + describe('swarm:trackQueryCompleted', () => { + it('forwards a valid SwarmQueryCompletedProps payload to analyticsClient.track', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + { + duration_ms: 142, + outcome: 'success', + result_count: 7, + swarm_scope: 'mixed', + tags: ['k1', 'k2'], + }, + 'client-1', + )) as SwarmTrackResponse + + expect(response).to.deep.equal({tracked: true}) + expect(analyticsClient.trackCalls).to.have.length(1) + const [call] = analyticsClient.trackCalls + expect(call.event).to.equal(AnalyticsEventNames.SWARM_QUERY_COMPLETED) + const props = call.properties as Record + expect(props.duration_ms).to.equal(142) + expect(props.outcome).to.equal('success') + expect(props.result_count).to.equal(7) + expect(props.swarm_scope).to.equal('mixed') + }) + + it('returns {tracked: false, reason: schema-rejection} for a payload missing required outcome', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler({duration_ms: 5}, 'client-1')) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('schema-rejection') + expect(analyticsClient.trackCalls).to.have.length(0) + }) + + it('emits failure_kind when the producer indicated a failure', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + await handler( + { + duration_ms: 88, + failure_kind: 'provider_timeout', + outcome: 'failure', + }, + 'client-1', + ) + + const props = analyticsClient.trackCalls[0].properties as Record + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('provider_timeout') + }) + }) + + describe('swarm:trackStoreCompleted', () => { + it('forwards a valid SwarmStoreCompletedProps payload', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_STORE_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + { + duration_ms: 234, + operation: 'update', + outcome: 'success', + skipped: 1, + stored: 2, + updated: 1, + }, + 'client-1', + )) as SwarmTrackResponse + + expect(response).to.deep.equal({tracked: true}) + const [call] = analyticsClient.trackCalls + expect(call.event).to.equal(AnalyticsEventNames.SWARM_STORE_COMPLETED) + const props = call.properties as Record + expect(props.operation).to.equal('update') + expect(props.stored).to.equal(2) + }) + + it('rejects when `operation` field is missing (required by schema)', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_STORE_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler({duration_ms: 5, outcome: 'success'}, 'client-1')) as SwarmTrackResponse + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('schema-rejection') + }) + }) + + describe('swarm:trackOnboarded', () => { + it('forwards a valid SwarmOnboardedProps payload', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_ONBOARDED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + { + duration_ms: 1024, + member_count: 3, + outcome: 'success', + swarm_kind: 'new', + }, + 'client-1', + )) as SwarmTrackResponse + + expect(response).to.deep.equal({tracked: true}) + const [call] = analyticsClient.trackCalls + expect(call.event).to.equal(AnalyticsEventNames.SWARM_ONBOARDED) + const props = call.properties as Record + expect(props.swarm_kind).to.equal('new') + expect(props.member_count).to.equal(3) + }) + }) + + describe('graceful degradation', () => { + // Run the degradation checks across every event so a future divergence + // (e.g. one handler refactored, others not) fails loudly. + const VALID_PAYLOAD_BY_EVENT: Record> = { + [SwarmEvents.TRACK_ONBOARDED]: {duration_ms: 1, member_count: 1, outcome: 'success', swarm_kind: 'new'}, + [SwarmEvents.TRACK_QUERY_COMPLETED]: {duration_ms: 1, outcome: 'success'}, + [SwarmEvents.TRACK_STORE_COMPLETED]: {duration_ms: 1, operation: 'create', outcome: 'success'}, + } + + for (const eventName of Object.values(SwarmEvents)) { + it(`returns {tracked: false, reason: analytics-unavailable} for ${eventName} when no analyticsClient is wired`, async () => { + const standaloneTransport = createMockTransportServer() + new SwarmHandler({transport: standaloneTransport}).setup() + const handler = standaloneTransport._handlers.get(eventName) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler(VALID_PAYLOAD_BY_EVENT[eventName], 'client-1')) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('analytics-unavailable') + }) + + it(`returns {tracked: false, reason: analytics-throw} for ${eventName} when track() throws`, async () => { + const handler = transport._handlers.get(eventName) + if (handler === undefined) throw new Error('handler not registered') + analyticsClient.trackThrows = new Error('queue full') + + const response = (await handler(VALID_PAYLOAD_BY_EVENT[eventName], 'client-1')) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('analytics-throw') + }) + } + }) +}) diff --git a/test/unit/infra/transport/handlers/vc-handler-analytics.test.ts b/test/unit/infra/transport/handlers/vc-handler-analytics.test.ts new file mode 100644 index 000000000..8c9b58063 --- /dev/null +++ b/test/unit/infra/transport/handlers/vc-handler-analytics.test.ts @@ -0,0 +1,460 @@ + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' +import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' +import type {IGitService} from '../../../../../src/server/core/interfaces/services/i-git-service.js' +import type {ISpaceService} from '../../../../../src/server/core/interfaces/services/i-space-service.js' +import type {ITeamService} from '../../../../../src/server/core/interfaces/services/i-team-service.js' +import type {IProjectConfigStore} from '../../../../../src/server/core/interfaces/storage/i-project-config-store.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' +import type {IVcGitConfigStore} from '../../../../../src/server/core/interfaces/vc/i-vc-git-config-store.js' + +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {VcHandler} from '../../../../../src/server/infra/transport/handlers/vc-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {VcEvents} from '../../../../../src/shared/transport/events/vc-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const PROJECT_PATH = '/fake/proj' +const CLIENT_ID = 'client-1' +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +interface VcDeps { + analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + contextTreeService: Stubbed + gitService: Stubbed + projectConfigStore: Stubbed + requestHandlers: Record + resolveProjectPath: SinonStub + spaceService: Stubbed + teamService: Stubbed + tokenStore: Stubbed + transport: Stubbed + vcGitConfigStore: Stubbed +} + +function makeDeps(sandbox: SinonSandbox): VcDeps { + const requestHandlers: Record = {} + const transport: Stubbed = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + const gitService: Stubbed = { + abortMerge: sandbox.stub().resolves(), + add: sandbox.stub().resolves(), + addRemote: sandbox.stub().resolves(), + checkout: sandbox.stub().resolves(), + clone: sandbox.stub().resolves(), + commit: sandbox.stub().resolves({ + author: {email: 'test@example.com', name: 'Test User'}, + message: 'test', + sha: 'abc123', + timestamp: new Date(), + }), + createBranch: sandbox.stub().resolves(), + deleteBranch: sandbox.stub().resolves(), + fetch: sandbox.stub().resolves(), + getAheadBehind: sandbox.stub().resolves({ahead: 0, behind: 0}), + getBlobContent: sandbox.stub().resolves(), + getBlobContents: sandbox.stub().resolves({}), + getConflicts: sandbox.stub().resolves([]), + getCurrentBranch: sandbox.stub().resolves('main'), + getFilesWithConflictMarkers: sandbox.stub().resolves([]), + getRemoteUrl: sandbox.stub().resolves(), + getTextBlob: sandbox.stub().resolves(), + getTrackingBranch: sandbox.stub().resolves({remote: 'origin', remoteBranch: 'main'}), + hashBlob: sandbox.stub().resolves('0000'), + init: sandbox.stub().resolves(), + isAncestor: sandbox.stub().resolves(true), + isEmptyRepository: sandbox.stub().resolves(false), + isInitialized: sandbox.stub().resolves(true), + listBranches: sandbox.stub().resolves([{isCurrent: true, isRemote: false, name: 'main'}]), + listChangedFiles: sandbox.stub().resolves([]), + listRemotes: sandbox.stub().resolves([{remote: 'origin', url: 'https://byterover.dev/team/space.git'}]), + log: sandbox.stub().resolves([{sha: 'abc', timestamp: new Date()} as never]), + merge: sandbox.stub().resolves({success: true}), + pull: sandbox.stub().resolves({success: true}), + push: sandbox.stub().resolves({success: true}), + removeRemote: sandbox.stub().resolves(), + reset: sandbox.stub().resolves({filesChanged: 0, headSha: 'abc'}), + setTrackingBranch: sandbox.stub().resolves(), + status: sandbox.stub().resolves({files: [{path: 'a.md', staged: true, status: 'modified'}], isClean: false}), + } + + const contextTreeService: Stubbed = { + delete: sandbox.stub().resolves(), + exists: sandbox.stub().resolves(false), + hasGitRepo: sandbox.stub().resolves(false), + initialize: sandbox.stub().resolves(`${PROJECT_PATH}/.brv/context-tree`), + resolvePath: sandbox.stub().returns(`${PROJECT_PATH}/.brv/context-tree`), + } + + const vcGitConfigStore: Stubbed = { + get: sandbox.stub().resolves({email: 'a@b.dev', name: 'A B'}), + set: sandbox.stub().resolves(), + } + + const token = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + userEmail: 'a@b.dev', + userId: 'u1', + }) + + const tokenStore: Stubbed = { + clear: sandbox.stub().resolves(), + load: sandbox.stub().resolves(token), + save: sandbox.stub().resolves(), + } + + return { + analyticsClient: makeFakeAnalyticsClient(), + contextTreeService, + gitService, + projectConfigStore: { + exists: sandbox.stub().resolves(false), + getModifiedTime: sandbox.stub().resolves(), + read: sandbox.stub().resolves(), + write: sandbox.stub().resolves(), + }, + requestHandlers, + resolveProjectPath: sandbox.stub().returns(PROJECT_PATH), + spaceService: {getSpaces: sandbox.stub().resolves({spaces: [], total: 0})}, + teamService: {getTeams: sandbox.stub().resolves({teams: [], total: 0})}, + tokenStore, + transport, + vcGitConfigStore, + } +} + +function makeHandler(deps: VcDeps): VcHandler { + const handler = new VcHandler({ + analyticsClient: deps.analyticsClient, + broadcastToProject: createSandbox().stub() as never, + contextTreeService: deps.contextTreeService, + gitRemoteBaseUrl: 'https://byterover.dev', + gitService: deps.gitService, + projectConfigStore: deps.projectConfigStore, + resolveProjectPath: deps.resolveProjectPath as never, + spaceService: deps.spaceService, + teamService: deps.teamService, + tokenStore: deps.tokenStore, + transport: deps.transport, + vcGitConfigStore: deps.vcGitConfigStore, + webAppUrl: 'https://app.byterover.dev', + }) + handler.setup() + return handler +} + +function invoke(deps: VcDeps, event: string, data: unknown): Promise { + return deps.requestHandlers[event](data, CLIENT_ID) as Promise +} + +function emitsOf(deps: VcDeps, name: string): Array<{args: unknown[]}> { + return deps.analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) +} + +describe('VcHandler analytics emits', () => { + let sandbox: SinonSandbox + let deps: VcDeps + + beforeEach(() => { + sandbox = createSandbox() + deps = makeDeps(sandbox) + deps.gitService.isInitialized.resolves(false) + makeHandler(deps) + }) + + afterEach(() => sandbox.restore()) + + it('emits vc_init outcome=success with had_existing_git_dir=false on fresh init', async () => { + await invoke(deps, VcEvents.INIT, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_INIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_existing_git_dir: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_existing_git_dir).to.equal(false) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits vc_init had_existing_git_dir=true when repo already exists', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.INIT, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_INIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_existing_git_dir: boolean; outcome: string} + expect(props.had_existing_git_dir).to.equal(true) + }) + + it('emits vc_commit on commit success with had_message=true', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.COMMIT, {message: 'feat: x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_COMMIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_message: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_message).to.equal(true) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits vc_fetched with remote_kind=byterover when remote points at the configured base', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.FETCH, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_FETCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; remote_kind: string} + expect(props.outcome).to.equal('success') + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_fetched with remote_kind=external when remote is unknown', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.listRemotes.resolves([{remote: 'origin', url: 'https://github.com/foo/bar.git'}]) + await invoke(deps, VcEvents.FETCH, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_FETCHED) + const props = calls[0].args[1] as {remote_kind: string} + expect(props.remote_kind).to.equal('external') + }) + + it('emits vc_pushed with branch_name_hash + remote_kind on push success', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.PUSH, {branch: 'feat/x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_PUSHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {branch_name_hash: string; outcome: string; remote_kind: string} + expect(props.outcome).to.equal('success') + expect(props.branch_name_hash).to.match(sha256HexRegex) + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_pulled with branch_name_hash + remote_kind on pull success', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.PULL, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_PULLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {branch_name_hash: string; remote_kind: string} + expect(props.branch_name_hash).to.match(sha256HexRegex) + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_reset_executed with reset_mode echoed from request', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.RESET, {mode: 'hard'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_RESET_EXECUTED) + const props = calls[0].args[1] as {outcome: string; reset_mode: string} + expect(props.outcome).to.equal('success') + expect(props.reset_mode).to.equal('hard') + }) + + it('emits vc_discarded with discard_scope=file on single-path discard', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.DISCARD, {filePaths: ['a.md']}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_DISCARDED) + const props = calls[0].args[1] as {discard_scope: string; outcome: string} + expect(props.discard_scope).to.equal('file') + expect(props.outcome).to.equal('success') + }) + + it('emits vc_discarded with discard_scope=all on multi-path discard', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.DISCARD, {filePaths: ['a.md', 'b.md']}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_DISCARDED) + const props = calls[0].args[1] as {discard_scope: string} + expect(props.discard_scope).to.equal('all') + }) + + it('emits vc_branched on create-branch dispatcher path', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.BRANCH, {action: 'create', name: 'feat/x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_BRANCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {from_default_branch: boolean; outcome: string} + expect(props.outcome).to.equal('success') + expect(props.from_default_branch).to.equal(true) + }) + + it('does NOT emit vc_branched on list/delete branch actions', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.listBranches.resolves([ + {isCurrent: false, isRemote: false, name: 'feat/x'}, + {isCurrent: true, isRemote: false, name: 'main'}, + ]) + await invoke(deps, VcEvents.BRANCH, {action: 'list'}) + await invoke(deps, VcEvents.BRANCH, {action: 'delete', name: 'feat/x'}) + expect(emitsOf(deps, AnalyticsEventNames.VC_BRANCHED).length).to.equal(0) + }) + + it('emits vc_checked_out with branch_kind=existing on plain checkout', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.CHECKOUT, {branch: 'main'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_CHECKED_OUT) + const props = calls[0].args[1] as {branch_kind: string} + expect(props.branch_kind).to.equal('existing') + }) + + it('emits vc_checked_out with branch_kind=created on -b checkout', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.listBranches.resolves([]) + await invoke(deps, VcEvents.CHECKOUT, {branch: 'feat/x', create: true}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_CHECKED_OUT) + const props = calls[0].args[1] as {branch_kind: string} + expect(props.branch_kind).to.equal('created') + }) + + it('emits vc_cloned outcome=success with project_path_hash + remote_kind', async () => { + // Fresh repo so clone runs (not "already initialized" early return) + deps.gitService.isInitialized.resolves(false) + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'T', id: 'tid', isActive: true, isDefault: false, name: 'teambao', slug: 'teambao'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{ + id: 'sid', + isDefault: false, + name: 'space1', + slug: 'space1', + teamId: 'tid', + teamName: 'teambao', + teamSlug: 'teambao', + }], + total: 1, + }) + await invoke(deps, VcEvents.CLONE, {url: 'https://byterover.dev/teambao/space1.git'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_CLONED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; project_path_hash: string; remote_kind: string} + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_merged on successful merge (fall-through path)', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({files: [], isClean: true}) + deps.gitService.getCurrentBranch.resolves('main') + deps.gitService.listBranches.resolves([ + {isCurrent: true, isRemote: false, name: 'main'}, + {isCurrent: false, isRemote: false, name: 'feat/x'}, + ]) + deps.gitService.merge.resolves({alreadyUpToDate: false, success: true}) + await invoke(deps, VcEvents.MERGE, {action: 'merge', branch: 'feat/x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_MERGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_fast_forward?: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_fast_forward).to.equal(false) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits vc_merged had_fast_forward=true on self-merge no-op', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({files: [], isClean: true}) + deps.gitService.getCurrentBranch.resolves('main') + await invoke(deps, VcEvents.MERGE, {action: 'merge', branch: 'main'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_MERGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_fast_forward?: boolean; outcome: string} + expect(props.had_fast_forward).to.equal(true) + }) + + it('emits vc_remote_changed change_kind=added on remote add', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.getRemoteUrl.resolves() + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'T', id: 'tid', isActive: true, isDefault: false, name: 'teambao', slug: 'teambao'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{ + id: 'sid', + isDefault: false, + name: 'space1', + slug: 'space1', + teamId: 'tid', + teamName: 'teambao', + teamSlug: 'teambao', + }], + total: 1, + }) + await invoke(deps, VcEvents.REMOTE, {subcommand: 'add', url: 'https://byterover.dev/teambao/space1.git'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_REMOTE_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {change_kind: string; remote_kind: string} + expect(props.change_kind).to.equal('added') + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_remote_changed with change_kind=removed on remove subcommand', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.getRemoteUrl.resolves('https://github.com/foo/bar.git') + await invoke(deps, VcEvents.REMOTE, {subcommand: 'remove'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_REMOTE_CHANGED) + const props = calls[0].args[1] as {change_kind: string; remote_kind: string} + expect(props.change_kind).to.equal('removed') + expect(props.remote_kind).to.equal('external') + }) + + it('does NOT emit vc_remote_changed on remote show', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.REMOTE, {subcommand: 'show'}) + expect(emitsOf(deps, AnalyticsEventNames.VC_REMOTE_CHANGED).length).to.equal(0) + }) + + it('is a no-op when analyticsClient is not injected', async () => { + sandbox.restore() + sandbox = createSandbox() + deps = makeDeps(sandbox) + deps.gitService.isInitialized.resolves(false) + const handler = new VcHandler({ + broadcastToProject: sandbox.stub() as never, + contextTreeService: deps.contextTreeService, + gitRemoteBaseUrl: 'https://byterover.dev', + gitService: deps.gitService, + projectConfigStore: deps.projectConfigStore, + resolveProjectPath: deps.resolveProjectPath as never, + spaceService: deps.spaceService, + teamService: deps.teamService, + tokenStore: deps.tokenStore, + transport: deps.transport, + vcGitConfigStore: deps.vcGitConfigStore, + webAppUrl: 'https://app.byterover.dev', + }) + handler.setup() + await invoke(deps, VcEvents.INIT, {}) + // No analytics client injected → trackSpy never called + expect(deps.analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/worktree-handler.test.ts b/test/unit/infra/transport/handlers/worktree-handler.test.ts new file mode 100644 index 000000000..b8d2bb751 --- /dev/null +++ b/test/unit/infra/transport/handlers/worktree-handler.test.ts @@ -0,0 +1,139 @@ + +import {expect} from 'chai' +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {WorktreeHandler} from '../../../../../src/server/infra/transport/handlers/worktree-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {WorktreeEvents} from '../../../../../src/shared/transport/events/worktree-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('WorktreeHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let worktreeDir: string + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + // Create a real project dir with .brv/config.json so addWorktree sees it as a BRV project + projectDir = mkdtempSync(join(tmpdir(), 'brv-wt-proj-')) + mkdirSync(join(projectDir, '.brv'), {recursive: true}) + writeFileSync(join(projectDir, '.brv', 'config.json'), '{}') + worktreeDir = mkdtempSync(join(tmpdir(), 'brv-wt-target-')) + + analyticsClient = makeFakeAnalyticsClient() + const resolveProjectPath = sandbox.stub().returns(projectDir) + new WorktreeHandler({ + analyticsClient, + resolveProjectPath: resolveProjectPath as never, + transport, + }).setup() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + rmSync(worktreeDir, {force: true, recursive: true}) + }) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits worktree_added outcome=success on add success', async () => { + const handler = requestHandlers[WorktreeEvents.ADD] + await handler({worktreePath: worktreeDir}, 'client-1') + const calls = emits(AnalyticsEventNames.WORKTREE_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits worktree_removed outcome=failure when target does not exist', async () => { + const handler = requestHandlers[WorktreeEvents.REMOVE] + const nonexistent = join(tmpdir(), `brv-wt-noexist-${Date.now()}`) + await handler({worktreePath: nonexistent}, 'client-1') + const calls = emits(AnalyticsEventNames.WORKTREE_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('remove_failed') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('does NOT emit on list', async () => { + const handler = requestHandlers[WorktreeEvents.LIST] + await handler({}, 'client-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + it('is a no-op when analyticsClient is not injected', async () => { + const requestHandlersLocal: Record = {} + const transportLocal: Stubbed = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlersLocal[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + new WorktreeHandler({ + resolveProjectPath: sandbox.stub().returns(projectDir) as never, + transport: transportLocal, + }).setup() + const handler = requestHandlersLocal[WorktreeEvents.ADD] + await handler({worktreePath: worktreeDir}, 'client-1') + // No throw, no spy invocations on the injected client either + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/socket-io-transport-server.test.ts b/test/unit/infra/transport/socket-io-transport-server.test.ts index 40d7eb21d..89fb68396 100644 --- a/test/unit/infra/transport/socket-io-transport-server.test.ts +++ b/test/unit/infra/transport/socket-io-transport-server.test.ts @@ -1,11 +1,14 @@ import {expect} from 'chai' import {Socket as ClientSocket, io} from 'socket.io-client' +import type {ClientType} from '../../../../src/server/core/domain/client/client-info.js' + import { TransportPortInUseError, TransportServerAlreadyRunningError, TransportServerNotStartedError, } from '../../../../src/server/core/domain/errors/transport-error.js' +import {getClientKindFromContext} from '../../../../src/server/infra/transport/client-kind-context.js' import {SocketIOTransportServer} from '../../../../src/server/infra/transport/socket-io-transport-server.js' describe('SocketIOTransportServer', () => { @@ -444,4 +447,76 @@ describe('SocketIOTransportServer', () => { expect(server.isRunning()).to.be.true }) }) + + describe('client_kind context wrap', () => { + it('exposes the registered ClientType inside the request handler via getClientKindFromContext', async () => { + const typeByClientId = new Map() + server.setGetClientKind((clientId) => typeByClientId.get(clientId)) + await server.start(9980) + + let observed: ClientType | undefined + server.onRequest('client-kind:probe', () => { + observed = getClientKindFromContext() + return {ok: true} + }) + + clientSocket = io('http://127.0.0.1:9980') + await new Promise((resolve) => { + clientSocket!.on('connect', resolve) + }) + + typeByClientId.set(clientSocket.id!, 'cli') + + await new Promise((resolve) => { + clientSocket!.emit('client-kind:probe', {}, () => resolve()) + }) + + expect(observed).to.equal('cli') + }) + + it('omits the wrap when the lookup returns undefined (no context set)', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const noopLookup: (clientId: string) => ClientType | undefined = () => undefined + server.setGetClientKind(noopLookup) + await server.start(9981) + + let observed: 'sentinel' | ClientType | undefined = 'sentinel' + server.onRequest('client-kind:probe', () => { + observed = getClientKindFromContext() + return {ok: true} + }) + + clientSocket = io('http://127.0.0.1:9981') + await new Promise((resolve) => { + clientSocket!.on('connect', resolve) + }) + + await new Promise((resolve) => { + clientSocket!.emit('client-kind:probe', {}, () => resolve()) + }) + + expect(observed).to.equal(undefined) + }) + + it('skips the wrap entirely when no getClientKind callback is registered (backward compat)', async () => { + await server.start(9982) + + let observed: 'sentinel' | ClientType | undefined = 'sentinel' + server.onRequest('client-kind:probe', () => { + observed = getClientKindFromContext() + return {ok: true} + }) + + clientSocket = io('http://127.0.0.1:9982') + await new Promise((resolve) => { + clientSocket!.on('connect', resolve) + }) + + await new Promise((resolve) => { + clientSocket!.emit('client-kind:probe', {}, () => resolve()) + }) + + expect(observed).to.equal(undefined) + }) + }) }) diff --git a/test/unit/oclif/lib/analytics-disclosure.test.ts b/test/unit/oclif/lib/analytics-disclosure.test.ts new file mode 100644 index 000000000..198057e73 --- /dev/null +++ b/test/unit/oclif/lib/analytics-disclosure.test.ts @@ -0,0 +1,153 @@ +import {expect} from 'chai' + +import { + collectConsent, + isInteractive, + loadDisclosure, +} from '../../../../src/oclif/lib/analytics-disclosure.js' + +describe('analytics-disclosure (M16.2 extracted lib)', () => { + describe('loadDisclosure', () => { + it('returns the disclosure markdown text (non-empty)', async () => { + const text = await loadDisclosure() + expect(text).to.be.a('string') + expect(text.length).to.be.greaterThan(0) + }) + }) + + describe('isInteractive', () => { + it('returns a boolean', () => { + // The value depends on the test runner's TTY state; we just assert the + // shape. Specific TTY/non-TTY behavior is exercised through collectConsent. + expect(isInteractive()).to.be.a('boolean') + }) + }) + + describe('collectConsent', () => { + it('returns true without prompting when yesFlag is set', async () => { + const logs: string[] = [] + let promptCalled = false + const result = await collectConsent({ + onError(): never { + throw new Error('onError should not be called when yesFlag is set') + }, + onLog: (msg) => logs.push(msg), + async promptFn() { + promptCalled = true + return true + }, + ttyCheck: () => false, // non-TTY + yesFlag: true, + }) + + expect(result).to.equal(true) + expect(promptCalled, 'prompt skipped when yesFlag is set').to.equal(false) + expect(logs.length, 'disclosure markdown logged once').to.equal(1) + }) + + it('calls onError when non-interactive and yesFlag is false', async () => { + const errors: string[] = [] + const logs: string[] = [] + class StopError extends Error {} + + try { + await collectConsent({ + onError(msg: string): never { + errors.push(msg) + throw new StopError() + }, + onLog: (msg) => logs.push(msg), + promptFn: async () => true, + ttyCheck: () => false, + yesFlag: false, + }) + expect.fail('expected onError to throw') + } catch (error) { + expect(error).to.be.instanceOf(StopError) + } + + expect(errors.length).to.equal(1) + expect(errors[0].toLowerCase()).to.match(/non-interactive|yes/) + }) + + it('calls the prompt and returns its result when interactive without yesFlag', async () => { + const logs: string[] = [] + const result = await collectConsent({ + onError(): never { + throw new Error('onError should not be called when TTY+prompt') + }, + onLog: (msg) => logs.push(msg), + promptFn: async () => true, + ttyCheck: () => true, + yesFlag: false, + }) + + expect(result).to.equal(true) + expect(logs.length).to.equal(1) + }) + + it('returns false when the prompt is declined', async () => { + const logs: string[] = [] + const result = await collectConsent({ + onError(): never { + throw new Error('not expected') + }, + onLog: (msg) => logs.push(msg), + promptFn: async () => false, + ttyCheck: () => true, + yesFlag: false, + }) + + expect(result).to.equal(false) + }) + + it('translates inquirer ExitPromptError (Ctrl-C) to a declined consent', async () => { + const logs: string[] = [] + // inquirer's ExitPromptError sets `name = 'ExitPromptError'`. We detect + // by name so this test does not depend on which `@inquirer/core` copy + // the running command actually loads (nested vs. hoisted node_modules). + class FakeExitPromptError extends Error { + public override readonly name = 'ExitPromptError' + } + + const result = await collectConsent({ + onError(): never { + throw new Error('onError should not be called on Ctrl-C') + }, + onLog: (msg) => logs.push(msg), + async promptFn() { + throw new FakeExitPromptError('User force closed the prompt with SIGINT') + }, + ttyCheck: () => true, + yesFlag: false, + }) + + expect(result).to.equal(false) + expect(logs.length, 'disclosure markdown was still logged before the prompt').to.equal(1) + }) + + it('re-throws non-ExitPromptError prompt failures so they are not swallowed', async () => { + const logs: string[] = [] + class BoomError extends Error { + public override readonly name = 'BoomError' + } + + try { + await collectConsent({ + onError(): never { + throw new Error('onError should not be called on non-Exit prompt failure') + }, + onLog: (msg) => logs.push(msg), + async promptFn() { + throw new BoomError('transport hiccup') + }, + ttyCheck: () => true, + yesFlag: false, + }) + expect.fail('expected BoomError to propagate') + } catch (error) { + expect(error).to.be.instanceOf(BoomError) + } + }) + }) +}) diff --git a/test/unit/oclif/lib/build-cli-metadata.test.ts b/test/unit/oclif/lib/build-cli-metadata.test.ts new file mode 100644 index 000000000..ee69dd637 --- /dev/null +++ b/test/unit/oclif/lib/build-cli-metadata.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import sinon from 'sinon' + +import {buildCliMetadata} from '../../../../src/oclif/lib/build-cli-metadata.js' +import {CliMetadataSchema} from '../../../../src/shared/analytics/cli-metadata-schema.js' + +const ENV_KEYS_TOUCHED = ['CI', 'TERM_PROGRAM', 'npm_config_user_agent'] as const + +const setIsTty = (value: boolean): void => { + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value, writable: true}) +} + +describe('buildCliMetadata', () => { + let originalEnv: Record + let originalIsTtyDescriptor: PropertyDescriptor | undefined + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + originalEnv = {} + for (const key of ENV_KEYS_TOUCHED) { + originalEnv[key] = process.env[key] + delete process.env[key] + } + + originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + setIsTty(false) + clock = sinon.useFakeTimers(1_700_000_000_000) + }) + + afterEach(() => { + for (const key of ENV_KEYS_TOUCHED) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } + + if (originalIsTtyDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTtyDescriptor) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + + clock.restore() + }) + + it('produces a CliMetadataSchema-parseable object', () => { + const result = buildCliMetadata('query', {flags: {format: 'text'}}) + expect(CliMetadataSchema.safeParse(result).success).to.equal(true) + }) + + it('sets command_id from the first argument and flag_names from user-passed flag keys', () => { + const result = buildCliMetadata('vc:add', {flags: {detach: true, format: 'text'}}) + expect(result.command_id).to.equal('vc:add') + expect(result.flag_names).to.have.members(['detach', 'format']) + expect(result.flag_names).to.have.lengthOf(2) + }) + + it('filters out flags that oclif populated from defaults (only user-passed flags survive)', () => { + // dream-style invocation: oclif parses `--force` from argv and fills + // detach/format/timeout/undo from static defaults. Only `force` is + // user-passed; the four defaults must not appear in flag_names. + const result = buildCliMetadata('dream', { + flags: {detach: false, force: true, format: 'text', timeout: 1800, undo: false}, + metadata: { + flags: { + detach: {setFromDefault: true}, + force: {setFromDefault: false}, + format: {setFromDefault: true}, + timeout: {setFromDefault: true}, + undo: {setFromDefault: true}, + }, + }, + }) + expect(result.flag_names).to.deep.equal(['force']) + }) + + it('treats absent metadata as "all flags are user-passed" (backward-compatible default)', () => { + // When the caller cannot supply metadata (legacy test fixtures), every + // key in flags is reported as user-passed. Matches the previous + // Object.keys(flags) behaviour for callers that have not been migrated. + const result = buildCliMetadata('vc:add', {flags: {detach: true, format: 'text'}}) + expect(result.flag_names).to.have.members(['detach', 'format']) + }) + + it('emits an empty flag_names array when no flags passed', () => { + const result = buildCliMetadata('status', {flags: {}}) + expect(result.flag_names).to.deep.equal([]) + }) + + it('sets client_sent_at to Date.now() (mocked here)', () => { + const result = buildCliMetadata('query', {flags: {}}) + expect(result.client_sent_at).to.equal(1_700_000_000_000) + }) + + describe('is_ci', () => { + it('false when CI env unset', () => { + const result = buildCliMetadata('query', {flags: {}}) + expect(result.is_ci).to.equal(false) + }) + + it('true when CI=true', () => { + process.env.CI = 'true' + const result = buildCliMetadata('query', {flags: {}}) + expect(result.is_ci).to.equal(true) + }) + + it('true when CI=1', () => { + process.env.CI = '1' + const result = buildCliMetadata('query', {flags: {}}) + expect(result.is_ci).to.equal(true) + }) + + it('false when CI=false (opt-out by convention)', () => { + process.env.CI = 'false' + const result = buildCliMetadata('query', {flags: {}}) + expect(result.is_ci).to.equal(false) + }) + }) + + describe('is_tty', () => { + it('false when stdout.isTTY is false', () => { + setIsTty(false) + const result = buildCliMetadata('query', {flags: {}}) + expect(result.is_tty).to.equal(false) + }) + + it('true when stdout.isTTY is true', () => { + setIsTty(true) + const result = buildCliMetadata('query', {flags: {}}) + expect(result.is_tty).to.equal(true) + }) + }) + + describe('package_manager', () => { + it('npm when npm_config_user_agent starts with "npm/"', () => { + process.env.npm_config_user_agent = 'npm/10.2.4 node/v20.0.0 darwin x64' + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('npm') + }) + + it('yarn when npm_config_user_agent starts with "yarn/"', () => { + process.env.npm_config_user_agent = 'yarn/1.22.19 npm/? node/v20.0.0 darwin x64' + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('yarn') + }) + + it('pnpm when npm_config_user_agent starts with "pnpm/"', () => { + process.env.npm_config_user_agent = 'pnpm/8.10.0 npm/? node/v20.0.0 darwin x64' + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('pnpm') + }) + + it('bun when npm_config_user_agent starts with "bun/"', () => { + process.env.npm_config_user_agent = 'bun/1.0.0 (linux x64)' + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('bun') + }) + + it('unknown when npm_config_user_agent unset', () => { + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('unknown') + }) + + it('unknown when npm_config_user_agent is some unrecognised prefix', () => { + process.env.npm_config_user_agent = 'rush/5 node/v20 darwin x64' + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('unknown') + }) + }) + + describe('runtime', () => { + it('node when process.versions.bun is absent (default test env)', () => { + expect(buildCliMetadata('q', {flags: {}}).runtime).to.equal('node') + }) + }) + + describe('terminal_program', () => { + it('omitted when TERM_PROGRAM unset', () => { + const result = buildCliMetadata('q', {flags: {}}) + expect(result).to.not.have.property('terminal_program') + }) + + it('omitted when TERM_PROGRAM is empty string', () => { + process.env.TERM_PROGRAM = '' + const result = buildCliMetadata('q', {flags: {}}) + expect(result).to.not.have.property('terminal_program') + }) + + it('included verbatim when TERM_PROGRAM is non-empty', () => { + process.env.TERM_PROGRAM = 'WezTerm' + const result = buildCliMetadata('q', {flags: {}}) + expect(result.terminal_program).to.equal('WezTerm') + }) + }) + + it('does not mutate the input flags object', () => { + const flags = {detach: true} + buildCliMetadata('q', {flags}) + expect(flags).to.deep.equal({detach: true}) + }) + + it('returns a fresh object per call (no shared mutable state)', () => { + const a = buildCliMetadata('q', {flags: {}}) + const b = buildCliMetadata('q', {flags: {}}) + expect(a).to.not.equal(b) + }) +}) diff --git a/test/unit/server/core/domain/analytics/batch.test.ts b/test/unit/server/core/domain/analytics/batch.test.ts new file mode 100644 index 000000000..440a4b50e --- /dev/null +++ b/test/unit/server/core/domain/analytics/batch.test.ts @@ -0,0 +1,227 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' + +const validIdentity = { + device_id: '550e8400-e29b-41d4-a716-446655440000', +} + +const eventA = { + created_at: '2023-11-14T22:13:20+00:00', + identity: validIdentity, + name: 'event_a', + properties: {x: 1}, +} + +const eventB = { + created_at: '2023-11-14T22:13:20.001+00:00', + identity: validIdentity, + name: 'event_b', + properties: {y: 'hello'}, +} + +describe('AnalyticsBatch', () => { + describe('create()', () => { + it('should create an empty batch', () => { + const batch = AnalyticsBatch.create([]) + + expect(batch.schema_version).to.equal(2) + expect(batch.events).to.deep.equal([]) + }) + + it('should create a populated batch preserving event order', () => { + const batch = AnalyticsBatch.create([eventA, eventB]) + + expect(batch.events).to.have.lengthOf(2) + expect(batch.events[0].name).to.equal('event_a') + expect(batch.events[1].name).to.equal('event_b') + }) + }) + + describe('toJson()', () => { + it('should serialize an empty batch', () => { + const batch = AnalyticsBatch.create([]) + + expect(batch.toJson()).to.deep.equal({events: [], schema_version: 2}) + }) + + it('should serialize a populated batch with all event fields', () => { + const batch = AnalyticsBatch.create([eventA]) + const json = batch.toJson() + + expect(json.schema_version).to.equal(2) + expect(json.events).to.have.lengthOf(1) + expect(json.events[0]).to.deep.equal(eventA) + }) + }) + + describe('round-trip', () => { + it('should round-trip an empty batch through fromJson', () => { + const original = AnalyticsBatch.create([]) + const restored = AnalyticsBatch.fromJson(original.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.schema_version).to.equal(2) + expect(restored?.events).to.deep.equal([]) + }) + + it('should round-trip a populated batch', () => { + const original = AnalyticsBatch.create([eventA, eventB]) + const restored = AnalyticsBatch.fromJson(original.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.events).to.have.lengthOf(2) + expect(restored?.events[0]).to.deep.equal(eventA) + expect(restored?.events[1]).to.deep.equal(eventB) + }) + }) + + describe('fromJson() rejects malformed input', () => { + it('should return undefined for null', () => { + expect(AnalyticsBatch.fromJson(null)).to.be.undefined + }) + + it('should return undefined for non-object primitives', () => { + expect(AnalyticsBatch.fromJson('string')).to.be.undefined + expect(AnalyticsBatch.fromJson(123)).to.be.undefined + expect(AnalyticsBatch.fromJson(true)).to.be.undefined + }) + + it('should return undefined for an array (top-level)', () => { + expect(AnalyticsBatch.fromJson([])).to.be.undefined + }) + + it('should return undefined when schema_version is missing', () => { + expect(AnalyticsBatch.fromJson({events: []})).to.be.undefined + }) + + it('should return undefined when schema_version is not 2', () => { + expect(AnalyticsBatch.fromJson({events: [], schema_version: 1})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: [], schema_version: 0})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: [], schema_version: '2'})).to.be.undefined + }) + + it('should return undefined when events is not an array', () => { + expect(AnalyticsBatch.fromJson({events: {}, schema_version: 2})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: 'foo', schema_version: 2})).to.be.undefined + expect(AnalyticsBatch.fromJson({schema_version: 2})).to.be.undefined + }) + + it('should return undefined when an event is missing name', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has non-string name', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 123, properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event is missing identity', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20+00:00', name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when identity is missing device_id', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: {}, name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when identity has empty device_id', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: {device_id: ''}, name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when identity carries an unexpected extra field (.strict())', () => { + const json = { + events: [ + { + created_at: '2023-11-14T22:13:20+00:00', + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000', extra_field: 'leak'}, + name: 'x', + properties: {}, + }, + ], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event is missing created_at', () => { + const json = { + events: [{identity: validIdentity, name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has a non-string created_at', () => { + const json = { + events: [{created_at: 1_700_000_000_000, identity: validIdentity, name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when created_at is missing a timezone designator', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20', identity: validIdentity, name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should accept created_at with Z suffix or numeric offset', () => { + for (const ts of ['2023-11-14T22:13:20Z', '2023-11-14T22:13:20+07:00', '2023-11-14T22:13:20.123-05:30']) { + const json = { + events: [{created_at: ts, identity: validIdentity, name: 'x', properties: {}}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json), `created_at=${ts} should parse`).to.not.be.undefined + } + }) + + it('should return undefined when an event carries a stray legacy timestamp field', () => { + // Wire schema is strict: events must be exactly {created_at, identity, name, properties}. + // A residual `timestamp` from a pre-upgrade producer must be rejected, matching the backend's + // `forbidNonWhitelisted` semantics in byterover-telemetry PR #21. + const json = { + events: [ + { + created_at: '2023-11-14T22:13:20+00:00', + identity: validIdentity, + name: 'x', + properties: {}, + timestamp: 1_700_000_000_000, + }, + ], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has non-object properties', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 'x', properties: 'foo'}], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + }) +}) diff --git a/test/unit/server/core/domain/entities/global-config.test.ts b/test/unit/server/core/domain/entities/global-config.test.ts new file mode 100644 index 000000000..346d16d1c --- /dev/null +++ b/test/unit/server/core/domain/entities/global-config.test.ts @@ -0,0 +1,275 @@ +import {expect} from 'chai' + +import {GLOBAL_CONFIG_VERSION} from '../../../../../../src/server/constants.js' +import {GlobalConfig} from '../../../../../../src/server/core/domain/entities/global-config.js' + +describe('GlobalConfig', () => { + const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + + describe('create()', () => { + it('should create a GlobalConfig with the given deviceId and current version', () => { + const config = GlobalConfig.create(validDeviceId) + + expect(config.deviceId).to.equal(validDeviceId) + expect(config.version).to.equal(GLOBAL_CONFIG_VERSION) + }) + + it('should throw an error when deviceId is empty', () => { + expect(() => GlobalConfig.create('')).to.throw('Device ID cannot be empty') + }) + + it('should throw an error when deviceId is only whitespace', () => { + expect(() => GlobalConfig.create(' ')).to.throw('Device ID cannot be empty') + }) + }) + + describe('fromJson()', () => { + it('should deserialize valid JSON', () => { + const json = { + deviceId: validDeviceId, + version: '0.0.1', + } + + const config = GlobalConfig.fromJson(json) + + expect(config).to.not.be.undefined + expect(config?.deviceId).to.equal(validDeviceId) + expect(config?.version).to.equal('0.0.1') + }) + + it('should return undefined for null', () => { + const config = GlobalConfig.fromJson(null) + + expect(config).to.be.undefined + }) + + it('should return undefined for non-object', () => { + expect(GlobalConfig.fromJson('string')).to.be.undefined + expect(GlobalConfig.fromJson(123)).to.be.undefined + expect(GlobalConfig.fromJson(true)).to.be.undefined + expect(GlobalConfig.fromJson([])).to.be.undefined + }) + + it('should return undefined when deviceId is missing', () => { + const json = {version: '0.0.1'} + + const config = GlobalConfig.fromJson(json) + + expect(config).to.be.undefined + }) + + it('should return undefined when deviceId is empty', () => { + const json = { + deviceId: '', + version: '0.0.1', + } + + const config = GlobalConfig.fromJson(json) + + expect(config).to.be.undefined + }) + + it('should return undefined when deviceId is only whitespace', () => { + const json = { + deviceId: ' ', + version: '0.0.1', + } + + const config = GlobalConfig.fromJson(json) + + expect(config).to.be.undefined + }) + + it('should return undefined when version is missing', () => { + const json = {deviceId: validDeviceId} + + const config = GlobalConfig.fromJson(json) + + expect(config).to.be.undefined + }) + + it('should return undefined when deviceId is not a string', () => { + const json = { + deviceId: 123, + version: '0.0.1', + } + + const config = GlobalConfig.fromJson(json) + + expect(config).to.be.undefined + }) + + it('should return undefined when version is not a string', () => { + const json = { + deviceId: validDeviceId, + version: 1, + } + + const config = GlobalConfig.fromJson(json) + + expect(config).to.be.undefined + }) + }) + + describe('toJson()', () => { + it('should serialize to JSON correctly', () => { + const config = GlobalConfig.create(validDeviceId) + const json = config.toJson() + + expect(json).to.deep.equal({ + analytics: false, + deviceId: validDeviceId, + version: GLOBAL_CONFIG_VERSION, + }) + }) + + it('should roundtrip through fromJson', () => { + const original = GlobalConfig.create(validDeviceId) + const json = original.toJson() + const restored = GlobalConfig.fromJson(json) + + expect(restored).to.not.be.undefined + expect(restored?.deviceId).to.equal(original.deviceId) + expect(restored?.version).to.equal(original.version) + expect(restored?.analytics).to.equal(original.analytics) + }) + }) + + describe('analytics field (ENG-2611)', () => { + it('should default analytics to false when absent (legacy upgrade)', () => { + const config = GlobalConfig.fromJson({deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.not.be.undefined + expect(config?.analytics).to.equal(false) + }) + + it('should preserve analytics: true when explicitly set', () => { + const config = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.not.be.undefined + expect(config?.analytics).to.equal(true) + }) + + it('should preserve analytics: false when explicitly set', () => { + const config = GlobalConfig.fromJson({analytics: false, deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.not.be.undefined + expect(config?.analytics).to.equal(false) + }) + + it('should reject non-boolean analytics value', () => { + const config = GlobalConfig.fromJson({analytics: 'yes', deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.be.undefined + }) + + it('should round-trip analytics: true through toJson/fromJson', () => { + const fromTrue = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + expect(fromTrue, 'fromJson must accept valid analytics: true input').to.not.be.undefined + if (!fromTrue) throw new Error('fromJson returned undefined for valid input') + const restoredTrue = GlobalConfig.fromJson(fromTrue.toJson()) + expect(restoredTrue?.analytics).to.equal(true) + + const fromFalse = GlobalConfig.fromJson({analytics: false, deviceId: validDeviceId, version: '0.0.1'}) + expect(fromFalse, 'fromJson must accept valid analytics: false input').to.not.be.undefined + if (!fromFalse) throw new Error('fromJson returned undefined for valid input') + const restoredFalse = GlobalConfig.fromJson(fromFalse.toJson()) + expect(restoredFalse?.analytics).to.equal(false) + }) + + it('should default analytics to false on create()', () => { + const config = GlobalConfig.create(validDeviceId) + + expect(config.analytics).to.equal(false) + }) + + it('should include analytics: false explicitly in toJson() of default-created instance', () => { + const config = GlobalConfig.create(validDeviceId) + const json = config.toJson() + + expect(json).to.have.property('analytics', false) + }) + }) + + describe('withAnalytics()', () => { + it('should produce a new instance with the given analytics value', () => { + const original = GlobalConfig.create(validDeviceId) + const updated = original.withAnalytics(true) + + expect(updated.analytics).to.equal(true) + expect(updated.deviceId).to.equal(validDeviceId) + expect(updated.version).to.equal(GLOBAL_CONFIG_VERSION) + }) + + it('should not mutate the original instance', () => { + const original = GlobalConfig.create(validDeviceId) + original.withAnalytics(true) + + expect(original.analytics).to.equal(false) + }) + + it('should return a new instance even when value matches current', () => { + const original = GlobalConfig.create(validDeviceId) + const same = original.withAnalytics(false) + + expect(same).to.not.equal(original) + expect(same.analytics).to.equal(false) + expect(same.deviceId).to.equal(original.deviceId) + }) + }) + + describe('withDeviceId()', () => { + const newDeviceId = '11111111-2222-3333-4444-555555555555' + + it('should produce a new instance with the given deviceId, preserving analytics + version', () => { + const original = GlobalConfig.fromJson({ + analytics: true, + deviceId: validDeviceId, + version: '0.0.1', + }) + if (!original) throw new Error('fromJson returned undefined for valid input') + + const updated = original.withDeviceId(newDeviceId) + + expect(updated.deviceId).to.equal(newDeviceId) + expect(updated.analytics).to.equal(true) + expect(updated.version).to.equal('0.0.1') + }) + + it('should not mutate the original instance', () => { + const original = GlobalConfig.create(validDeviceId) + original.withDeviceId(newDeviceId) + + expect(original.deviceId).to.equal(validDeviceId) + }) + + it('should return a new instance object', () => { + const original = GlobalConfig.create(validDeviceId) + const updated = original.withDeviceId(newDeviceId) + + expect(updated).to.not.equal(original) + }) + + it('should throw when deviceId is empty', () => { + const original = GlobalConfig.create(validDeviceId) + + expect(() => original.withDeviceId('')).to.throw('Device ID cannot be empty') + }) + + it('should throw when deviceId is only whitespace', () => { + const original = GlobalConfig.create(validDeviceId) + + expect(() => original.withDeviceId(' ')).to.throw('Device ID cannot be empty') + }) + }) + + describe('immutability', () => { + it('should have readonly properties', () => { + const config = GlobalConfig.create(validDeviceId) + + // TypeScript prevents this at compile time, but we verify the values don't change + expect(config.deviceId).to.equal(validDeviceId) + expect(config.version).to.equal(GLOBAL_CONFIG_VERSION) + }) + }) +}) diff --git a/test/unit/server/core/domain/transport/analytics-track-schema.test.ts b/test/unit/server/core/domain/transport/analytics-track-schema.test.ts new file mode 100644 index 000000000..a941f7a1c --- /dev/null +++ b/test/unit/server/core/domain/transport/analytics-track-schema.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {AnalyticsTrackPayloadSchema} from '../../../../../../src/shared/transport/events/analytics-events.js' + +describe('AnalyticsTrackPayloadSchema', () => { + describe('valid payloads', () => { + it('should accept {event} only', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'cli_invocation'}) + expect(result.success).to.equal(true) + }) + + it('should accept {event, properties}', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({ + event: 'cli_invocation', + properties: {command_id: 'status'}, + }) + expect(result.success).to.equal(true) + }) + + it('should accept empty properties object', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'e', properties: {}}) + expect(result.success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('should reject missing event', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({properties: {x: 1}}) + expect(result.success).to.equal(false) + }) + + it('should reject empty-string event', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: ''}) + expect(result.success).to.equal(false) + }) + + it('should reject non-string event', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 42}) + expect(result.success).to.equal(false) + }) + + it('should reject non-object properties', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'e', properties: 'oops'}) + expect(result.success).to.equal(false) + }) + + it('should reject array properties', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'e', properties: [1, 2]}) + expect(result.success).to.equal(false) + }) + + it('should reject null payload', () => { + const result = AnalyticsTrackPayloadSchema.safeParse(null) + expect(result.success).to.equal(false) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts b/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts new file mode 100644 index 000000000..bfd2fb3da --- /dev/null +++ b/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts @@ -0,0 +1,213 @@ +import {expect} from 'chai' + +import {AnalyticsBackoffPolicy} from '../../../../../src/server/infra/analytics/analytics-backoff-policy.js' + +/** + * M4.5 backoff policy: 30s → 60s → 2m → 5m, cap at 5m. First success + * resets to 30s. Reachability state (healthy / degraded / unreachable) + * is derived from `consecutiveFailures()` by M4.6, not exposed here. + */ +describe('AnalyticsBackoffPolicy (M4.5)', () => { + describe('initial state', () => { + it('starts at 30s with zero consecutive failures', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.nextDelayMs(), 'base interval is 30s').to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('repeated nextDelayMs() calls do NOT advance the policy (read-only)', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.consecutiveFailures(), 'reading state must not mutate').to.equal(0) + }) + }) + + describe('exponential backoff schedule', () => { + it('after 1 failure: 60s', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(60_000) + expect(policy.consecutiveFailures()).to.equal(1) + }) + + it('after 2 failures: 2 minutes (120s)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(120_000) + expect(policy.consecutiveFailures()).to.equal(2) + }) + + it('after 3 failures: 5 minutes (300s)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onFailure() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(300_000) + expect(policy.consecutiveFailures()).to.equal(3) + }) + + it('after 4 failures: still 5 minutes (capped)', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 4; i++) policy.onFailure() + expect(policy.nextDelayMs(), 'cap holds at 5m').to.equal(300_000) + expect(policy.consecutiveFailures()).to.equal(4) + }) + + it('after many failures: still capped at 5 minutes, counter keeps growing', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 50; i++) policy.onFailure() + expect(policy.nextDelayMs()).to.equal(300_000) + expect(policy.consecutiveFailures(), 'counter is unbounded for reachability classification').to.equal(50) + }) + }) + + describe('reset on success', () => { + it('onSuccess() from clean state stays at 30s with zero failures', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onSuccess() + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('onSuccess() after 1 failure resets to 30s with zero failures', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onSuccess() + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('onSuccess() after the cap resets to 30s with zero failures', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 10; i++) policy.onFailure() + expect(policy.nextDelayMs()).to.equal(300_000) + policy.onSuccess() + expect(policy.nextDelayMs(), 'cap-then-success must drop straight to 30s').to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('failure-success-failure pattern advances from the base, not the prior peak', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(120_000) + policy.onSuccess() + policy.onFailure() + expect(policy.nextDelayMs(), 'after success we start the schedule fresh').to.equal(60_000) + }) + }) + + describe('reachability counter (M4.6 will derive labels from this)', () => { + it('counter starts at 0 → healthy zone', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('counter at 1-2 → degraded zone (M4.6 mapping)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + expect(policy.consecutiveFailures(), '1 failure').to.equal(1) + policy.onFailure() + expect(policy.consecutiveFailures(), '2 failures').to.equal(2) + }) + + it('counter at 3+ → unreachable zone (M4.6 mapping)', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 3; i++) policy.onFailure() + expect(policy.consecutiveFailures()).to.equal(3) + }) + + it('onSuccess() returns counter to 0 (unreachable → healthy)', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 5; i++) policy.onFailure() + expect(policy.consecutiveFailures()).to.equal(5) + policy.onSuccess() + expect(policy.consecutiveFailures(), 'first success collapses any unreachable count').to.equal(0) + }) + }) + + describe('server-hint override (M5.4 honor Retry-After — ENG-2658)', () => { + it('applyServerHint overrides the base 30s delay with the larger server value', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(120_000) + expect(policy.nextDelayMs(), 'server asked for 120s, base is 30s -> 120s').to.equal(120_000) + }) + + it('clamps an absurdly large server hint to the 1h safe maximum (no setTimeout overflow / multi-day stall)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(315_360_000_000) // 10 years — would overflow Node's setTimeout (> 2^31-1 ms) + expect( + policy.nextDelayMs(), + 'a hostile/buggy server cannot stall shipping for days nor overflow setTimeout', + ).to.equal(3_600_000) // capped at 1 hour + }) + + it('honors a server hint at the cap boundary verbatim', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(3_600_000) // exactly 1h — within the cap + expect(policy.nextDelayMs()).to.equal(3_600_000) + }) + + it('applyServerHint with a non-positive / NaN / Infinity hint still flips isRateLimited (no delay floor)', () => { + // Load-bearing for the rate_limited reachability classification AND for the + // contract-violation path in AnalyticsClient (which marks the policy + // rate-limited via applyServerHint(NaN) so the burst gate stays closed). + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(bad) + expect(policy.isRateLimited(), `hint=${bad} must still surface rate-limited`).to.equal(true) + expect(policy.nextDelayMs(), `hint=${bad} must not change the schedule floor`).to.equal(30_000) + } + }) + + it('applyServerHint never accelerates below the current schedule delay', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() // current schedule delay is now 60s + policy.applyServerHint(5000) + expect( + policy.nextDelayMs(), + 'a misbehaving server cannot pull retries under the safe minimum', + ).to.equal(60_000) + }) + + it('does NOT count a server hint as a consecutive failure (429/503 is not unreachable)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(120_000) + expect(policy.consecutiveFailures(), 'rate-limit is not a reachability failure').to.equal(0) + }) + + it('isRateLimited() flips true on applyServerHint and is false from a clean state', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.isRateLimited(), 'clean state is not rate-limited').to.equal(false) + policy.applyServerHint(30_000) + expect(policy.isRateLimited()).to.equal(true) + }) + + it('three consecutive server hints stay rate-limited with zero failures (never unreachable)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(60_000) + policy.applyServerHint(60_000) + policy.applyServerHint(60_000) + expect(policy.consecutiveFailures(), 'repeated 429s do not bump the unreachable counter').to.equal(0) + expect(policy.isRateLimited()).to.equal(true) + }) + + it('onSuccess clears the server hint and the rate-limited flag', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(300_000) + policy.onSuccess() + expect(policy.nextDelayMs(), 'success drops back to the base interval').to.equal(30_000) + expect(policy.isRateLimited()).to.equal(false) + }) + + it('a real transient failure supersedes the server hint and resumes the exponential schedule', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(300_000) + policy.onFailure() + expect(policy.isRateLimited(), 'a 5xx after a 429 is no longer a rate-limit').to.equal(false) + expect(policy.nextDelayMs(), 'pure exponential after the failure (1 failure -> 60s)').to.equal(60_000) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts new file mode 100644 index 000000000..57b521b9b --- /dev/null +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -0,0 +1,1729 @@ +/* eslint-disable camelcase, max-lines -- this file accumulates AnalyticsClient cases across M2-M4.6; splitting would scatter the contract surface. */ +import {expect} from 'chai' +import {spy, stub} from 'sinon' + +import type {Identity} from '../../../../../src/server/core/domain/analytics/identity.js' +import type {IAnalyticsSender, SendResult} from '../../../../../src/server/core/interfaces/analytics/i-analytics-sender.js' +import type {IIdentityResolver} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IJsonlAnalyticsStore, JsonlAnalyticsStoreUpdateStatus} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {ISuperPropertiesResolver, SuperProperties} from '../../../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' +import type {CurateOperationAppliedProps} from '../../../../../src/shared/analytics/events/curate-operation-applied.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsClient} from '../../../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' +import {NoOpAnalyticsSender} from '../../../../../src/server/infra/analytics/no-op-analytics-sender.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +/** + * Valid CurateOperationAppliedProps payload used for tests that need a real + * typed event with non-trivial properties (e.g. property merge / precedence). + * DAEMON_START is used elsewhere where the call shape only needs to fire. + */ +function makeCurateOpProps(overrides: Partial = {}): CurateOperationAppliedProps { + return { + keywords: [], + knowledge_path: 'cli_architecture/test.md', + needs_review: false, + operation_type: 'ADD', + relative_path: 'tmp/file.md', + tags: [], + task_id: 'task-1', + ...overrides, + } +} + +type FakeJsonlStore = IJsonlAnalyticsStore & { + appendSpy: ReturnType + readonly records: StoredAnalyticsRecord[] + readonly updateStatusCalls: Array<{ids: readonly string[]; status: JsonlAnalyticsStoreUpdateStatus}> +} + +function makeFakeJsonlStore(opts: {appendError?: Error} = {}): FakeJsonlStore { + const records: StoredAnalyticsRecord[] = [] + const updateStatusCalls: Array<{ids: readonly string[]; status: JsonlAnalyticsStoreUpdateStatus}> = [] + const appendImpl = async (record: StoredAnalyticsRecord): Promise => { + if (opts.appendError) throw opts.appendError + records.push(record) + } + + const appendSpy = spy(appendImpl) + return { + append: appendSpy, + appendSpy, + async clear(): Promise { + records.length = 0 + }, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + list: async () => ({rows: [...records], total: records.length}), + loadPending: async () => records.filter((r) => r.status === 'pending'), + records, + // Simplified mirror of M9.2's updateStatus for unit tests: 'sent' is a terminal flip; + // 'failed' flips status directly. The real retry-cap (increment attempts, stay + // 'pending' until cap) lives in M9.2 and is verified end-to-end in M10.3. + async updateStatus(ids: readonly string[], status: JsonlAnalyticsStoreUpdateStatus): Promise { + updateStatusCalls.push({ids: [...ids], status}) + if (ids.length === 0) return + const idSet = new Set(ids) + for (let i = 0; i < records.length; i++) { + if (idSet.has(records[i].id)) records[i] = {...records[i], status} + } + }, + updateStatusCalls, + } +} + +type FakeSender = IAnalyticsSender & { + readonly calls: Array +} + +type FakeSenderOpts = + | {error: Error; kind: 'throw';} + | {failedIds: readonly string[]; kind: 'mixed'; succeededIds: readonly string[]} + | {kind: 'all-failed'} + | {kind: 'all-succeeded'} + +function makeFakeSender(opts?: FakeSenderOpts): FakeSender { + const resolved: FakeSenderOpts = opts ?? {kind: 'all-succeeded'} + const calls: Array = [] + return { + calls, + async send(records: readonly StoredAnalyticsRecord[]): Promise { + calls.push([...records]) + switch (resolved.kind) { + case 'all-failed': { + return {failed: records.map((r) => r.id), succeeded: []} + } + + case 'all-succeeded': { + return {failed: [], succeeded: records.map((r) => r.id)} + } + + case 'mixed': { + return {failed: [...resolved.failedIds], succeeded: [...resolved.succeededIds]} + } + + case 'throw': { + throw resolved.error + } + } + }, + } +} + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeAnonIdentity(): Identity { + return {device_id: validDeviceId} +} + +function makeRegisteredIdentity(): Identity { + return { + device_id: validDeviceId, + email: 'alice@example.com', + name: 'Alice', + user_id: 'user-123', + } +} + +function makeSuperProps(): SuperProperties { + return { + cli_version: '3.10.3', + device_id: validDeviceId, + environment: 'production', + node_version: 'v24.13.1', + os: 'darwin', + } +} + +function makeStubIdentityResolver(identity: Identity): IIdentityResolver { + return {resolve: stub().resolves(identity)} +} + +function makeStubSuperPropsResolver(props: SuperProperties): ISuperPropertiesResolver { + return {resolve: stub().resolves(props)} +} + +async function flushMicrotasks(): Promise { + // Drain the microtask queue so fire-and-forget async work completes + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) +} + +async function seedPending(client: AnalyticsClient, count: number): Promise { + for (let i = 0; i < count; i++) { + client.track(AnalyticsEventNames.DAEMON_START) + } + + await flushMicrotasks() +} + +// M4.5: hand-rolled sender that returns a tagged failure on demand. +// Hoisted to module scope to satisfy unicorn/consistent-function-scoping. +// `reason` is optional so the success-path callers can omit it +// (the autofix would otherwise rewrite `(undefined)` to `()`). +function makeSenderWithReason( + reason?: 'http_4xx' | 'http_5xx' | 'network' | 'rate_limited' | 'timeout', + retryAfterMs?: number, +): IAnalyticsSender { + return { + async send(records) { + if (reason === undefined) { + return {failed: [], succeeded: records.map((r) => r.id)} + } + + return { + failed: records.map((r) => r.id), + reason, + ...(retryAfterMs === undefined ? {} : {retryAfterMs}), + succeeded: [], + } + }, + } +} + +describe('AnalyticsClient', () => { + describe('disabled state (M4.4 semantic: track local-only)', () => { + // Pre-M4.4 this test asserted "no-op when disabled" (no JSONL append, + // no queue push, no resolver calls). Post-M4.4 the semantic is + // "local tracking always; remote send only when enabled" — disable + // gates the FLUSH layer, not the TRACK layer. `brv settings set analytics.share false` + // means "stop shipping to remote", not "stop collecting locally". + it('still tracks (JSONL + queue + resolvers) when isEnabled returns false; flush is the gate', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const identityResolver = makeStubIdentityResolver(makeAnonIdentity()) + const superPropsResolver = makeStubSuperPropsResolver(makeSuperProps()) + const sender = makeFakeSender() + + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => false, + jsonlStore, + queue, + sender, + superPropsResolver, + }) + + for (let i = 0; i < 5; i++) { + client.track(AnalyticsEventNames.DAEMON_START) + } + + await flushMicrotasks() + + expect(queue.size(), 'queue STILL grows when disabled (local tracking unconditional)').to.equal(5) + expect(jsonlStore.records.length, 'JSONL STILL appended when disabled').to.equal(5) + expect((identityResolver.resolve as ReturnType).called, 'resolvers still run').to.be.true + + // Flush is the gate now: it must NOT call sender when disabled. + await client.flush() + expect(sender.calls.length, 'flush must NOT call sender when disabled').to.equal(0) + }) + }) + + describe('enabled state (ticket scenario 2)', () => { + it('should resolve identity + super-props and push to queue with timestamp', async () => { + const queue = new BoundedQueue() + const identity = makeRegisteredIdentity() + const superProps = makeSuperProps() + const jsonlStore = makeFakeJsonlStore() + + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(identity), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(superProps), + }) + + const before = Date.now() + const opProps = makeCurateOpProps({relative_path: 'tmp/merge-fixture.md'}) + client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, opProps) + await flushMicrotasks() + const after = Date.now() + + const batch = await client.flush() + + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + expect(event.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(event.identity).to.deep.equal(identity) + // Local numeric `timestamp` is captured at call-site at millisecond precision. + expect(jsonlStore.records[0].timestamp).to.be.at.least(before) + expect(jsonlStore.records[0].timestamp).to.be.at.most(after) + // Wire `created_at` is the ISO 8601 string derived from the same `new Date()` + // read. `formatISO` drops millis, so it describes the same instant floored + // to the second. + expect(event.created_at).to.be.a('string') + expect(Date.parse(event.created_at)).to.equal( + Math.floor(jsonlStore.records[0].timestamp / 1000) * 1000, + ) + + // user properties merged through + expect(event.properties.relative_path).to.equal('tmp/merge-fixture.md') + expect(event.properties.operation_type).to.equal('ADD') + // all 5 super properties stamped + expect(event.properties.cli_version).to.equal('3.10.3') + expect(event.properties.device_id).to.equal(validDeviceId) + expect(event.properties.environment).to.equal('production') + expect(event.properties.node_version).to.equal('v24.13.1') + expect(event.properties.os).to.equal('darwin') + }) + }) + + describe('auth transition mid-batch (ticket scenario 3)', () => { + it('should reflect per-track identity resolution when auth state flips', async () => { + const queue = new BoundedQueue() + let currentIdentity: Identity = makeAnonIdentity() + const identityResolver: IIdentityResolver = { + resolve: async () => currentIdentity, + } + const superPropsResolver = makeStubSuperPropsResolver(makeSuperProps()) + + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue, + sender: makeFakeSender(), + superPropsResolver, + }) + + client.track(AnalyticsEventNames.DAEMON_START) + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + currentIdentity = makeRegisteredIdentity() + client.track(AnalyticsEventNames.DAEMON_START) + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(4) + expect(batch.events[0].identity).to.deep.equal(makeAnonIdentity()) + expect(batch.events[1].identity).to.deep.equal(makeAnonIdentity()) + expect(batch.events[2].identity).to.deep.equal(makeRegisteredIdentity()) + expect(batch.events[3].identity).to.deep.equal(makeRegisteredIdentity()) + }) + }) + + describe('M10.2 burst-overflow regression: flush reads from JSONL, not the bounded queue', () => { + it('should ship every tracked event even when the in-memory queue dropped half during a burst', async () => { + // M10.2's central architectural call: flush() reads from JSONL via loadPending(), + // NOT from the in-memory queue. Without this, events tracked beyond queue.maxSize + // would be silently dropped from the active flush path until daemon restart. + const queue = new BoundedQueue(5) + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 10; i++) { + client.track(AnalyticsEventNames.DAEMON_START) + } + + await flushMicrotasks() + + const batch = await client.flush() + // All 10 events durably stored and flushed — JSONL is the source of truth. + expect(batch.events).to.have.lengthOf(10) + expect(jsonlStore.records).to.have.lengthOf(10) + // The queue still honors its cap (the regression here is independent of queue eviction). + expect(queue.droppedCount()).to.equal(5) + }) + }) + + describe('flush returns valid AnalyticsBatch (ticket scenario 5)', () => { + it('should return a batch that round-trips through fromJson', async () => { + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + const batch = await client.flush() + const restored = AnalyticsBatch.fromJson(batch.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.events).to.have.lengthOf(1) + expect(restored?.events[0].name).to.equal(AnalyticsEventNames.DAEMON_START) + }) + + it('should return an empty batch when the queue has been fully drained', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const first = await client.flush() + expect(first.events).to.deep.equal([]) + }) + }) + + describe('error containment (analytics must not crash consumers)', () => { + it('should silently drop the event when identity resolution rejects', async () => { + const queue = new BoundedQueue() + const identityResolver: IIdentityResolver = { + resolve: () => Promise.reject(new Error('identity boom')), + } + + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Must not throw to the caller + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + }) + + it('should silently drop the event when super-properties resolution rejects', async () => { + const queue = new BoundedQueue() + const superPropsResolver: ISuperPropertiesResolver = { + resolve: () => Promise.reject(new Error('super-props boom')), + } + + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue, + sender: makeFakeSender(), + superPropsResolver, + }) + + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + }) + }) + + describe('timestamp captured at call site, not resolver settle time', () => { + it('should stamp timestamp when track() is called even if resolvers settle later', async () => { + const queue = new BoundedQueue() + let resolveIdentity!: (id: Identity) => void + const slowIdentityResolver: IIdentityResolver = { + resolve: () => + new Promise((resolve) => { + resolveIdentity = resolve + }), + } + + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: slowIdentityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const before = Date.now() + client.track(AnalyticsEventNames.DAEMON_START) + const after = Date.now() + + // Hold the resolver pending across a real timer gap so settle-time and + // call-time diverge meaningfully — without this the bug is too subtle to detect. + await new Promise((resolve) => { + setTimeout(resolve, 50) + }) + + const settleStart = Date.now() + resolveIdentity(makeAnonIdentity()) + await flushMicrotasks() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(1) + // Captured-at-call: local numeric `timestamp` (millisecond precision) + // falls within the call-site window… + const stored = jsonlStore.records[0] + expect(stored.timestamp).to.be.at.least(before) + expect(stored.timestamp).to.be.at.most(after) + // …and is BEFORE the resolver settled (proving capture-at-call, not capture-at-settle). + expect(stored.timestamp).to.be.lessThan(settleStart) + // The wire-bound `created_at` is derived from the same `new Date()` read, + // floored to the second by formatISO. + expect(Date.parse(batch.events[0].created_at)).to.equal( + Math.floor(stored.timestamp / 1000) * 1000, + ) + }) + }) + + describe('M9.3 JSONL-first persistence (dual write)', () => { + it('should append to JSONL before pushing to queue (happy path)', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, makeCurateOpProps()) + await flushMicrotasks() + + // JSONL has the row + expect(jsonlStore.records).to.have.lengthOf(1) + const stored = jsonlStore.records[0] + expect(stored.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(stored.status).to.equal('pending') + expect(stored.attempts).to.equal(0) + expect(stored.id).to.be.a('string').and.have.length.greaterThan(0) + // Queue mirror has the same record (id propagates) + expect(queue.size()).to.equal(1) + const [drained] = queue.drain() + expect(drained.id).to.equal(stored.id) + }) + + it('should generate distinct uuid id per track call', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 5; i++) { + client.track(AnalyticsEventNames.DAEMON_START) + } + + await flushMicrotasks() + + const ids = jsonlStore.records.map((r) => r.id) + expect(new Set(ids).size).to.equal(5) // all distinct + expect(jsonlStore.records).to.have.lengthOf(5) + }) + + it('should NOT push to queue when JSONL append fails', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore({appendError: new Error('disk full')}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + await flushMicrotasks() + + // JSONL append rejected (called once, but no record persisted) + expect(jsonlStore.appendSpy.calledOnce).to.equal(true) + expect(jsonlStore.records).to.have.lengthOf(0) + // Queue must NOT receive the event when JSONL persist failed + expect(queue.size()).to.equal(0) + }) + + it('should NOT push to queue and NOT crash when JSONL fails on every track', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore({appendError: new Error('persistent disk error')}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 100; i++) { + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + } + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + }) + + it('should track queue.size() growth equal to JSONL row count under non-burst load', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const N = 20 + for (let i = 0; i < N; i++) { + client.track(AnalyticsEventNames.DAEMON_START) + } + + await flushMicrotasks() + + expect(queue.size()).to.equal(N) + expect(jsonlStore.records).to.have.lengthOf(N) + }) + + it('STILL calls jsonlStore.append when analytics disabled (M4.4: local tracking unconditional)', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => false, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(jsonlStore.appendSpy.calledOnce, 'append fires regardless of enable state').to.be.true + expect(jsonlStore.records).to.have.lengthOf(1) + expect(queue.size()).to.equal(1) + }) + }) + + describe('M4.3 onAfterTrack hook (threshold notification)', () => { + it('fires onAfterTrack after a successful JSONL+queue persist', async () => { + const jsonlStore = makeFakeJsonlStore() + const onAfterTrack = spy() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + onAfterTrack: () => onAfterTrack(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(onAfterTrack.calledOnce).to.equal(true) + }) + + it('does NOT fire onAfterTrack when JSONL append fails (no record landed)', async () => { + const jsonlStore = makeFakeJsonlStore({appendError: new Error('disk full')}) + const onAfterTrack = spy() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + onAfterTrack: () => onAfterTrack(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(onAfterTrack.called, 'failed persist must not signal the scheduler').to.equal(false) + }) + + it('fires onAfterTrack once per successful track', async () => { + const onAfterTrack = spy() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + onAfterTrack: () => onAfterTrack(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 5; i++) client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(onAfterTrack.callCount).to.equal(5) + }) + + it('does NOT crash when onAfterTrack throws (analytics no-crash guarantee)', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + onAfterTrack() { + throw new Error('scheduler boom') + }, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + await flushMicrotasks() + }) + }) + + describe('M10.2 mirror flush: invokes sender, mirrors result back to JSONL via updateStatus', () => { + it('should pass loadPending records to sender.send exactly once per flush', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + await client.flush() + + expect(sender.calls).to.have.lengthOf(1) + const [shipped] = sender.calls + expect(shipped).to.have.lengthOf(3) + // All seeded via DAEMON_START so every shipped row carries the same name. + expect(shipped.map((r) => r.name)).to.deep.equal([ + AnalyticsEventNames.DAEMON_START, + AnalyticsEventNames.DAEMON_START, + AnalyticsEventNames.DAEMON_START, + ]) + }) + + it('should mirror all-succeeded result by flipping rows to status=sent', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender({kind: 'all-succeeded'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + await client.flush() + + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(['sent', 'sent', 'sent']) + // updateStatus(succeeded, 'sent') called with all 3 ids; updateStatus(failed, 'failed') called with empty + const calls = jsonlStore.updateStatusCalls + expect(calls.find((c) => c.status === 'sent')?.ids).to.have.lengthOf(3) + expect(calls.find((c) => c.status === 'failed')?.ids).to.have.lengthOf(0) + }) + + it('should mirror all-failed result by flipping rows to status=failed', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender({kind: 'all-failed'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 2) + await client.flush() + + // Note: real M9.2 keeps rows at 'pending' until MAX_ATTEMPTS — the FAKE store flips to + // 'failed' immediately for unit-test simplicity. End-to-end retry-cap composition is + // verified in M10.3 against the real JsonlAnalyticsStore. + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(['failed', 'failed']) + const calls = jsonlStore.updateStatusCalls + expect(calls.find((c) => c.status === 'failed')?.ids).to.have.lengthOf(2) + expect(calls.find((c) => c.status === 'sent')?.ids).to.have.lengthOf(0) + }) + + it('should mirror mixed result: some ids to sent, some to failed', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + // Late-bound: build the mixed sender with the actual record ids after seeding. + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 4) + const ids = jsonlStore.records.map((r) => r.id) + // Re-construct client with a mixed sender keyed off the seeded ids. + const jsonlStore2 = makeFakeJsonlStore() + const client2 = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: jsonlStore2, + queue: new BoundedQueue(), + sender: makeFakeSender({failedIds: [ids[2], ids[3]], kind: 'mixed', succeededIds: [ids[0], ids[1]]}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Re-seed with the SAME ids by appending records directly into jsonlStore2.records. + for (const r of jsonlStore.records) jsonlStore2.records.push(r) + + await client2.flush() + + // First two sent, last two flipped to failed (per fake-store simplified policy). + expect(jsonlStore2.records.find((r) => r.id === ids[0])?.status).to.equal('sent') + expect(jsonlStore2.records.find((r) => r.id === ids[1])?.status).to.equal('sent') + expect(jsonlStore2.records.find((r) => r.id === ids[2])?.status).to.equal('failed') + expect(jsonlStore2.records.find((r) => r.id === ids[3])?.status).to.equal('failed') + }) + + it('should treat a sender that throws as all-failed (no daemon crash)', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender({error: new Error('network boom'), kind: 'throw'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + + // The flush itself must not throw — daemon survives. + let threw = false + try { + await client.flush() + } catch { + threw = true + } + + expect(threw, 'flush MUST NOT throw when sender throws').to.equal(false) + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(['failed', 'failed', 'failed']) + }) + + it('should leave JSONL untouched when the no-op sender is wired (regression for review issue #4)', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: new NoOpAnalyticsSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 5) + const beforeStatuses = jsonlStore.records.map((r) => r.status) + const beforeAttempts = jsonlStore.records.map((r) => r.attempts) + + await client.flush() + + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(beforeStatuses) + expect(jsonlStore.records.map((r) => r.attempts)).to.deep.equal(beforeAttempts) + // Both updateStatus calls received empty arrays (no-op sender returns {[],[]}). + expect(jsonlStore.updateStatusCalls).to.deep.equal([ + {ids: [], status: 'sent'}, + {ids: [], status: 'failed'}, + ]) + }) + + it('should return a wire-shape AnalyticsBatch (id/attempts/status stripped via toWireEvent)', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + const batch = await client.flush() + + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + expect(event).to.have.property('name', AnalyticsEventNames.DAEMON_START) + expect(event).to.have.property('created_at') + expect(event).to.have.property('properties') + expect(event).to.have.property('identity') + // Local-only fields stripped on the wire. + expect(event).to.not.have.property('id') + expect(event).to.not.have.property('attempts') + expect(event).to.not.have.property('status') + expect(event).to.not.have.property('timestamp') + }) + }) + + describe('M4.1 onAuthTransition: clear pending events on login/logout', () => { + it('should empty the JSONL store and the in-memory queue on transition', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 5) + expect(jsonlStore.records).to.have.lengthOf(5) + expect(queue.size()).to.equal(5) + + await client.onAuthTransition() + + expect(jsonlStore.records).to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + + it('should be a no-op when there is nothing to drop', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // No throw, even when JSONL/queue are already empty. + await client.onAuthTransition() + + expect(jsonlStore.records).to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + + it('should NOT crash the consumer when clear() throws on disk error', async () => { + const queue = new BoundedQueue() + const baseStore = makeFakeJsonlStore() + const erroringStore = { + ...baseStore, + async clear(): Promise { + throw new Error('disk full') + }, + } + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: erroringStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // The await must resolve, not reject. + let threw = false + try { + await client.onAuthTransition() + } catch { + threw = true + } + + expect(threw, 'onAuthTransition must swallow disk errors').to.equal(false) + // In-memory queue cleared regardless of JSONL error. + expect(queue.size()).to.equal(0) + }) + + it('should leave subsequently tracked events visible to flush (post-transition events ship under the new session)', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + let currentIdentity: Identity = makeAnonIdentity() + const identityResolver: IIdentityResolver = { + resolve: async () => currentIdentity, + } + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Pre-transition tracks + client.track(AnalyticsEventNames.DAEMON_START) + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + // Simulate a login transition: identity flips, queue is cleared. + currentIdentity = makeRegisteredIdentity() + await client.onAuthTransition() + + // Post-transition tracks + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + const batch = await client.flush() + // Only the post-transition event is visible to flush. + expect(batch.events).to.have.lengthOf(1) + expect(batch.events[0].identity).to.deep.equal(makeRegisteredIdentity()) + }) + + it('should await in-flight tracks before clearing so no append lands after clear', async () => { + // Regression for the race window: a `track()` call that resolved + // identity before the transition but had not yet appended would, + // without the barrier, enqueue its append AFTER onAuthTransition's + // clear and persist a stale-identity record. The barrier awaits + // every in-flight track promise before issuing clear(). + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + let releaseIdentity!: (id: Identity) => void + const slowIdentityResolver: IIdentityResolver = { + resolve: () => + new Promise((resolve) => { + releaseIdentity = resolve + }), + } + const client = new AnalyticsClient({ + identityResolver: slowIdentityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Fire a track; identityResolver is pending so trackAsync is + // stuck pre-append. + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + // Nothing on disk yet. + expect(jsonlStore.records).to.have.lengthOf(0) + + // Kick off transition; clear MUST wait for the in-flight track. + const transitionPromise = client.onAuthTransition() + // Yield once so onAuthTransition reaches its `await + // Promise.allSettled([...pendingTracks])` before we release the + // identity. Without this yield, releaseIdentity runs before + // transitionPromise's body executes its first await, and the + // barrier snapshot may miss the race we are trying to cover. + await Promise.resolve() + // Resolve the identity AFTER the barrier is in place. Append will + // race with clear. The barrier guarantees clear runs LAST. + releaseIdentity(makeAnonIdentity()) + await transitionPromise + + // Final state: clear nuked the stale-identity append. + expect(jsonlStore.records, 'no record may survive a transition that ran after a track started').to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + + it('should NOT block new tracks that start AFTER onAuthTransition began', async () => { + // The barrier only awaits tracks already in-flight at the moment + // onAuthTransition starts. Tracks that arrive after the snapshot + // get the new identity and must persist normally. + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + let currentIdentity: Identity = makeAnonIdentity() + const identityResolver: IIdentityResolver = { + resolve: async () => currentIdentity, + } + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + currentIdentity = makeRegisteredIdentity() + await client.onAuthTransition() + + // New track after transition completed; uses new identity. + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(jsonlStore.records).to.have.lengthOf(1) + expect(jsonlStore.records[0].identity).to.deep.equal(makeRegisteredIdentity()) + }) + + it('should surface clear() failures through the optional log sink (M4.1 visibility)', async () => { + const queue = new BoundedQueue() + const baseStore = makeFakeJsonlStore() + const erroringStore = { + ...baseStore, + async clear(): Promise { + throw new Error('disk full') + }, + } + const logged: string[] = [] + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: erroringStore, + log: (msg) => logged.push(msg), + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await client.onAuthTransition() + + expect(logged).to.have.lengthOf(1) + expect(logged[0]).to.include('clear failed') + expect(logged[0]).to.include('disk full') + }) + + it('should remain crash-free when no log sink is wired and clear() throws', async () => { + // Regression: log sink is optional; absent log must not turn a + // disk error into an uncaught rejection. + const queue = new BoundedQueue() + const baseStore = makeFakeJsonlStore() + const erroringStore = { + ...baseStore, + async clear(): Promise { + throw new Error('disk full') + }, + } + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: erroringStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + let threw = false + try { + await client.onAuthTransition() + } catch { + threw = true + } + + expect(threw).to.equal(false) + }) + }) + + describe('M10.2 single-flight: concurrent flush() invocations collapse to one underlying run', () => { + it('should call sender.send only once when two flush() calls are awaited in parallel', async () => { + // Without single-flight, both flushes load the same pending set, both call sender, and + // both mirror updateStatus(failed, ids) into the writeChain — the writes serialize but + // attempts get double-incremented (cycle counter advances 2x). The single-flight guard + // makes a concurrent call join the in-flight promise instead. + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender({kind: 'all-failed'}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + + const [batchA, batchB] = await Promise.all([client.flush(), client.flush()]) + + // Single-flight collapsed both calls into one sender invocation with the same record set. + expect(sender.calls, 'two concurrent flushes must share one sender.send invocation').to.have.lengthOf(1) + // Concurrent callers receive the same batch object (joined in-flight promise). + expect(batchA).to.equal(batchB) + // updateStatus(failed, ids) called exactly once for the failed branch (succeeded branch + // is also called once with []). + const failedCalls = jsonlStore.updateStatusCalls.filter((c) => c.status === 'failed' && c.ids.length > 0) + expect(failedCalls, 'failed-updateStatus must run exactly once across the two concurrent flushes').to.have.lengthOf(1) + }) + + it('should release the in-flight slot after the flush settles so the next call runs fresh', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender({kind: 'all-succeeded'}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + await client.flush() // first flush: sender called, record marked sent + // After settle, loadPending now returns [] because record is 'sent'. + // The next flush should run fresh (NOT return the previous batch). + const second = await client.flush() + + expect(sender.calls, 'sequential flushes must each invoke sender').to.have.lengthOf(2) + expect(second.events, 'second flush sees no pending rows after first settled').to.deep.equal([]) + }) + }) + + describe('M4.4 flush gate: disabled state skips remote send, leaves JSONL intact', () => { + it('flush() returns empty batch and does NOT call sender when isEnabled returns false', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender() + // Pre-seed JSONL with a pending record (simulating an event tracked + // BEFORE the user disabled analytics — backlog scenario). + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 3) + expect(jsonlStore.records, 'precondition: 3 pending').to.have.lengthOf(3) + + // Now disable and flush. Records MUST stay `pending` in JSONL; + // re-enable later ships them on the next scheduler tick. + const disabledClient = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => false, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const batch = await disabledClient.flush() + + expect(batch.events, 'disabled flush returns empty batch').to.deep.equal([]) + expect(sender.calls, 'disabled flush must NOT call sender').to.have.lengthOf(0) + expect( + jsonlStore.records.every((r) => r.status === 'pending'), + 'disabled flush must leave JSONL records as pending (backlog preserved)', + ).to.be.true + }) + + it('flush() ships the backlog after re-enable (disabled → enabled transition resumes shipping)', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender() + let enabled = false + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => enabled, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Track 5 events while disabled — JSONL still grows. + await seedPending(client, 5) + expect(jsonlStore.records).to.have.lengthOf(5) + + // Flush while disabled — no-op on sender, backlog stays pending. + await client.flush() + expect(sender.calls).to.have.lengthOf(0) + + // Re-enable + flush — backlog ships. + enabled = true + await client.flush() + expect(sender.calls, 'enabled flush ships the backlog').to.have.lengthOf(1) + expect(sender.calls[0]).to.have.lengthOf(5) + }) + }) + + describe('M4.4 abort(): cancels in-flight flush via signal piped to sender', () => { + it('abort() during in-flight flush causes sender to receive an aborted signal', async () => { + let observedSignal: AbortSignal | undefined + let releaseSend!: () => void + const sender: IAnalyticsSender = { + async send(records, options) { + observedSignal = options?.signal + await new Promise((resolve) => { + releaseSend = resolve + }) + // Mimic HttpAnalyticsSender on abort: all-failed. + return {failed: records.map((r) => r.id), succeeded: []} + }, + } + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 2) + const flushPromise = client.flush() + await flushMicrotasks() + // Sender is now in-flight; abort the client. + client.abort() + expect(observedSignal?.aborted, 'sender must observe signal.aborted=true after abort()').to.equal(true) + + // Release the sender to let flush settle. JSONL records get marked + // failed (not stuck pending) per the existing failure-classification + // path; that's M9.2's retry-cap concern, not M4.4's. + releaseSend() + await flushPromise + }) + + it('abort() is a no-op when no flush is in flight', () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Must not throw. + client.abort() + }) + + it('does NOT bump M9.2 attempts on aborted flush (records stay pending for next enabled flush)', async () => { + // Regression for N3 review finding: without this skip, every + // disable-during-flush would call updateStatus(failed, 'failed') + // which bumps `attempts` via the M9.2 retry-cap. A few + // disable/enable toggles during shipping could drive records to + // attempts >= MAX_ATTEMPTS and terminate them — silent data loss. + let releaseSend!: () => void + const sender: IAnalyticsSender = { + async send(records, _options) { + await new Promise((resolve) => { + releaseSend = resolve + }) + // Mimic the abort-classification path: all-failed. + return {failed: records.map((r) => r.id), succeeded: []} + }, + } + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + expect(jsonlStore.records.every((r) => r.status === 'pending')).to.be.true + + const flushPromise = client.flush() + await flushMicrotasks() + client.abort() + releaseSend() + await flushPromise + + const failedUpdates = jsonlStore.updateStatusCalls.filter((c) => c.status === 'failed' && c.ids.length > 0) + expect( + failedUpdates, + 'aborted flush must NOT call updateStatus(_, failed) with any ids — preserves M9.2 attempts', + ).to.have.lengthOf(0) + expect( + jsonlStore.records.every((r) => r.status === 'pending'), + 'records remain pending so the next enabled flush ships them cleanly', + ).to.be.true + }) + }) + + describe('M4.5 backoff policy feedback', () => { + type StubPolicy = { + applyServerHint: ReturnType + consecutiveFailures: () => number + isRateLimited: () => boolean + nextDelayMs: () => number + onFailure: ReturnType + onSuccess: ReturnType + } + + function makePolicyStub(): StubPolicy { + return { + applyServerHint: stub(), + consecutiveFailures: () => 0, + isRateLimited: () => false, + nextDelayMs: () => 30_000, + onFailure: stub(), + onSuccess: stub(), + } + } + + it('calls policy.onSuccess() when the batch fully succeeds', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 2) + await client.flush() + + expect(policy.onSuccess.calledOnce, 'success advances onSuccess once').to.be.true + expect(policy.onFailure.called).to.be.false + }) + + it('calls policy.onFailure() on http_5xx (transient → back off)', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_5xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.calledOnce).to.be.true + expect(policy.onSuccess.called).to.be.false + }) + + it('calls policy.onFailure() on timeout', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('timeout'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.calledOnce).to.be.true + }) + + it('calls policy.onFailure() on network failure', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('network'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.calledOnce).to.be.true + }) + + it('does NOT advance the policy on http_4xx (payload shape is wrong, not a backend health signal)', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_4xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.called, '4xx must NOT advance backoff').to.be.false + expect(policy.onSuccess.called, '4xx is not a success either').to.be.false + }) + + it('honors a rate_limited result via applyServerHint and does NOT advance the failure counter (M5.4 — ENG-2658)', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('rate_limited', 120_000), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.applyServerHint.calledOnceWithExactly(120_000), 'server hint is forwarded to the policy').to.be + .true + expect(policy.onFailure.called, 'a 429/503 is reachable, not a failure').to.be.false + expect(policy.onSuccess.called, 'a rate-limit is not a success either').to.be.false + }) + + it('on a rate_limited result without retryAfterMs: logs AND still marks the policy rate-limited so the burst gate engages (M5.4 — ENG-2658)', async () => { + const policy = makePolicyStub() + const logs: string[] = [] + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + log: (m) => logs.push(m), + queue: new BoundedQueue(), + sender: makeSenderWithReason('rate_limited'), // no retryAfterMs — malformed per the contract + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + // Still flip the rate-limited bit (with an invalid sentinel so no delay + // floor is set) so the scheduler's burst gate stays closed — otherwise the + // next 20-event burst would hammer a server we were just told to back off from. + expect(policy.applyServerHint.calledOnce, 'the rate-limited bit must still be flipped').to.be.true + expect( + Number.isFinite(policy.applyServerHint.firstCall.args[0]), + 'called with a non-finite sentinel so no delay floor is applied', + ).to.equal(false) + expect(policy.onFailure.called, 'still not a reachability failure').to.be.false + expect( + logs.some((m) => m.includes('rate_limited') && /retryAfterMs|hint|burst|suppress/i.test(m)), + 'the dropped hint must be surfaced in the daemon log, not silently swallowed', + ).to.equal(true) + }) + + it('does NOT touch the policy when abort() fired during the flush (user-driven cancel, not a backend signal)', async () => { + const policy = makePolicyStub() + let releaseSend!: () => void + const sender: IAnalyticsSender = { + async send(records, _options) { + await new Promise((resolve) => { + releaseSend = resolve + }) + // Mimic the abort-classification path: all-failed with network. + return {failed: records.map((r) => r.id), reason: 'network', succeeded: []} + }, + } + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + const flushPromise = client.flush() + await flushMicrotasks() + client.abort() + releaseSend() + await flushPromise + + expect(policy.onFailure.called, 'abort-driven failure must NOT poison the M4.6 reachability counter').to.be.false + expect(policy.onSuccess.called).to.be.false + }) + + it('works without a backoff policy wired (back-compat: dep is optional)', async () => { + // No backoffPolicy in deps — sender returns http_5xx — must not crash. + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_5xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + // No assertion needed beyond "did not throw". + }) + + it('does NOT call onSuccess() on an uncategorized failed-without-reason result', async () => { + // Regression for review finding I1: prior code treated `reason === undefined` + // as success and called `onSuccess()`. A `{failed: ids, succeeded: [], + // reason: undefined}` result is a "we never shipped" outcome, NOT a clean + // ship — resetting backoff here would wrongly clear the unreachable + // counter. (The missing-deviceId path now classifies as `http_4xx` and is + // guarded separately above; this case covers any other uncategorized + // failure.) Should skip entirely. + const policy = makePolicyStub() + const sender: IAnalyticsSender = { + async send(records) { + // Mimic an uncategorized failure: failed-with-no-reason. + return {failed: records.map((r) => r.id), succeeded: []} + }, + } + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onSuccess.called, 'failed-without-reason must NOT call onSuccess').to.be.false + expect(policy.onFailure.called, 'failed-without-reason must NOT call onFailure either').to.be.false + }) + }) + + describe('M4.6 runtime state tracking', () => { + /** + * `lastSuccessfulFlushAt` is the timestamp shown by `brv settings get analytics.status` + * as "Last successful flush". Updated ONLY on a real clean ship — + * same gate as M4.5's backoff `onSuccess()`. Aborted, 4xx, failed, + * and empty-batch outcomes leave it untouched. The `now: () => number` + * dep is injected for deterministic assertions. + */ + it('lastSuccessfulFlushAt is undefined on a fresh client', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt, 'no flush has run yet').to.equal(undefined) + }) + + it('lastSuccessfulFlushAt is set to now() after a clean successful flush', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeFakeSender({kind: 'all-succeeded'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 2) + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt).to.equal(1_700_000_000_000) + }) + + it('lastSuccessfulFlushAt is NOT updated when the flush fails (sender returns reason)', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_5xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt, 'failed flush must not advance the timestamp').to.equal(undefined) + }) + + it('lastSuccessfulFlushAt is NOT updated on http_4xx (payload-shape error)', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_4xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt).to.equal(undefined) + }) + + it('lastSuccessfulFlushAt is NOT updated on an empty-batch no-op flush', async () => { + // No records seeded; flush still resolves but ships nothing. + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt, 'empty-batch flush is not a real ship').to.equal(undefined) + }) + + it('getRuntimeState surfaces JSONL pending count (NOT in-memory mirror) and droppedCount', async () => { + // Two pending records, one already-sent record. queueDepth should + // see only the pending row; dropped count surfaces from the queue. + const queue = new BoundedQueue(5) + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + now: () => 1_700_000_000_000, + queue, + sender: makeFakeSender({kind: 'all-succeeded'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Seed 3 events, flush 1 batch (all-succeeded), then add 2 more. + await seedPending(client, 3) + await client.flush() // 3 records → 'sent' + await seedPending(client, 2) // 2 new 'pending' records + + const state = await client.getRuntimeState() + expect(state.queueDepth, 'JSONL pending count, NOT queue.size()').to.equal(2) + // No drops in this scenario. + expect(state.droppedCount).to.equal(0) + }) + + it('getRuntimeState reflects droppedCount when the bounded queue evicts oldest', async () => { + // Cap of 2; pushing 4 records evicts the first 2. + const queue = new BoundedQueue(2) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 4) + const state = await client.getRuntimeState() + expect(state.droppedCount, 'queue dropped 2 of 4 oldest events').to.equal(2) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts new file mode 100644 index 000000000..f1a891363 --- /dev/null +++ b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts @@ -0,0 +1,570 @@ + +import {expect} from 'chai' +import sinon from 'sinon' + +import {AnalyticsFlushScheduler} from '../../../../../src/server/infra/analytics/analytics-flush-scheduler.js' + +type Deps = { + flush: sinon.SinonStub + isEnabled: sinon.SinonStub + pendingCount: sinon.SinonStub + queueSize: sinon.SinonStub +} + +function buildDeps( + overrides: Partial<{ + enabled: boolean + flushImpl: () => Promise + /** + * Shared depth for both `queueSize` (sync, threshold trigger) and + * `pendingCount` (async, empty-skip gate). Tests that want to + * distinguish the two paths override one stub explicitly after this + * call; the default keeps them in sync to mirror the steady-state + * production invariant (a record pushed is a record pending). + */ + size: number + }> = {}, +): Deps { + const size = overrides.size ?? 0 + return { + flush: sinon.stub().callsFake(overrides.flushImpl ?? (async () => {})), + isEnabled: sinon.stub().returns(overrides.enabled ?? true), + pendingCount: sinon.stub().resolves(size), + queueSize: sinon.stub().returns(size), + } +} + +// Shared fixture: a `flush` impl that never settles. Used by the +// timeout-budget tests to prove `flushFinal` resolves on the timer side +// of the race regardless of how slow the underlying flush is. +const neverResolvingFlush = (): Promise => + new Promise(() => { + /* intentional never-settle */ + }) + +async function flushMicrotasks(): Promise { + // Drain microtasks AND setImmediate so notifyPushed's scheduled flush runs. + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) +} + +describe('AnalyticsFlushScheduler', () => { + describe('interval timer', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('does NOT flush before the interval elapses', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(29_000) + + expect(deps.flush.called).to.equal(false) + scheduler.stop() + }) + + it('flushes once when the interval elapses with a non-empty queue', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + + expect(deps.flush.calledOnce).to.equal(true) + scheduler.stop() + }) + + it('does NOT flush at the interval when the queue is empty', async () => { + const deps = buildDeps({size: 0}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(60_000) + + expect(deps.flush.called).to.equal(false) + scheduler.stop() + }) + + it('gates the empty-skip on pendingCount, NOT queueSize (mirror-non-zero with pending=0 is silent)', async () => { + // Regression for the queue-mirror-never-decrements behavior: the + // in-memory queue grows on push but is only drained on auth + // transitions, so after a successful flush queueSize() > 0 yet + // pendingCount() === 0. The scheduler must consult the JSONL- + // backed pendingCount; using queueSize would re-fire flushes + // every 30s forever for an empty backlog. + const deps = buildDeps({size: 0}) // pendingCount + queueSize default sync + deps.queueSize.returns(50) // mirror still reflects past pushes + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(90_000) // three intervals + + expect(deps.flush.called, 'mirror-non-zero with pending=0 must NOT trigger').to.equal(false) + scheduler.stop() + }) + + it('skips the tick when analytics is disabled', async () => { + const deps = buildDeps({enabled: false, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(60_000) + + expect(deps.flush.called).to.equal(false) + scheduler.stop() + }) + + it('fires every interval, not just once (recurring timer)', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + await clock.tickAsync(30_000) + await clock.tickAsync(30_000) + + expect(deps.flush.callCount).to.equal(3) + scheduler.stop() + }) + + it('stop() halts further ticks', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + await clock.tickAsync(30_000) + scheduler.stop() + + await clock.tickAsync(60_000) + + expect(deps.flush.callCount).to.equal(1) + }) + + it('start() is idempotent (double-start does NOT install two timers)', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + scheduler.start() + + await clock.tickAsync(30_000) + + expect(deps.flush.callCount).to.equal(1) + scheduler.stop() + }) + + it('M4.5: re-reads nextIntervalMs() on every re-arm (dynamic backoff takes effect on next tick)', async () => { + // The whole point of converting setInterval to a setTimeout chain + // in M4.5 is that the next-tick delay can change AFTER each tick + // settles. A backoff policy that advances 30 → 60 → 120 between + // ticks must produce exactly that gap pattern at the scheduler. + // + // The mutation MUST happen inside the flush (before `.finally` + // re-arms), matching production: `AnalyticsClient.runFlush` + // updates the policy after `sender.send` returns, then resolves — + // and only then does the scheduler's `.finally` read the new value. + let currentInterval = 30_000 + const policyAdvanceQueue: Array<() => void> = [ + () => { + currentInterval = 60_000 + }, + () => { + currentInterval = 120_000 + }, + ] + const flushImpl = async (): Promise => { + const next = policyAdvanceQueue.shift() + if (next) next() + } + + const deps = buildDeps({flushImpl, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => currentInterval}) + scheduler.start() + + // Tick 1 fires at +30s; flush body sets currentInterval=60_000 + // BEFORE the .finally re-arms. The next setTimeout is therefore + // armed at +60s from tick 1. + await clock.tickAsync(30_000) + expect(deps.flush.callCount, 'tick 1 at 30s').to.equal(1) + + // 30s after tick 1 is NOT enough — the next arm is 60s. + await clock.tickAsync(30_000) + expect(deps.flush.callCount, 'still 1 at +60s (next arm is 60s)').to.equal(1) + + // Reach the 60s mark from tick 1's settle: tick 2 fires; flush + // body sets currentInterval=120_000 before the re-arm. + await clock.tickAsync(30_000) + expect(deps.flush.callCount, 'tick 2 fires once 60s elapsed since tick 1').to.equal(2) + + // 60s after tick 2 is NOT enough for the 120s arm. + await clock.tickAsync(60_000) + expect(deps.flush.callCount, 'still 2 — 120s arm has not elapsed').to.equal(2) + + // Reach the 120s mark from tick 2. + await clock.tickAsync(60_000) + expect(deps.flush.callCount, 'tick 3 fires once 120s elapsed since tick 2').to.equal(3) + + scheduler.stop() + }) + + it('M4.5: defaults to 30s when nextIntervalMs is not provided (back-compat)', async () => { + // Existing test fakes that omit the dep continue to work — and + // the default is the same 30s constant M4.3 shipped with. + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler(deps) + scheduler.start() + + await clock.tickAsync(29_999) + expect(deps.flush.called, 'must not fire before 30s').to.equal(false) + await clock.tickAsync(1) + expect(deps.flush.calledOnce, 'fires at exactly 30s').to.equal(true) + scheduler.stop() + }) + }) + + describe('threshold trigger via notifyPushed()', () => { + it('flushes via setImmediate when queue.size() crosses the threshold', async () => { + const deps = buildDeps({size: 20}) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + // `notifyPushed` returns synchronously; flush runs on the next setImmediate tick. + expect(deps.flush.called, 'flush must be deferred, not synchronous').to.equal(false) + + await flushMicrotasks() + + expect(deps.flush.calledOnce).to.equal(true) + }) + + it('does NOT flush when queue.size() is below the threshold', async () => { + const deps = buildDeps({size: 19}) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.called).to.equal(false) + }) + + it('does NOT flush when analytics is disabled', async () => { + const deps = buildDeps({enabled: false, size: 100}) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.called).to.equal(false) + }) + + it('does NOT fire a threshold flush while rate-limited; the stretched periodic tick handles it (M5.4 — ENG-2658)', async () => { + const deps = buildDeps({size: 100}) // well past the threshold + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + isRateLimited: () => true, // an active server 429/503 rate-limit + thresholdCount: 20, + }) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.called, 'a burst must not hammer a backend that asked us to wait').to.equal(false) + }) + + it('still fires the threshold flush when NOT rate-limited (normal batching preserved)', async () => { + const deps = buildDeps({size: 100}) + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + isRateLimited: () => false, + thresholdCount: 20, + }) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.calledOnce, 'normal-case batching is unchanged').to.equal(true) + }) + + it('does NOT re-trigger between threshold multiples (regression: queue mirror is monotonic past 20 → every push would fire)', async () => { + // The queue mirror only decrements on auth-transition drain, NOT on a + // successful flush. Without a moving baseline, queueSize >= 20 stays + // true forever after the first crossing, so every subsequent track + // would schedule a fresh setImmediate→tryFlush→HTTP POST and the + // 20-event batching contract would collapse for slow-emit workloads. + const deps = buildDeps({size: 5}) // pendingCount > 0 so tryFlush proceeds past the empty-skip gate + deps.queueSize.onCall(0).returns(20) + deps.queueSize.onCall(1).returns(21) + deps.queueSize.onCall(2).returns(22) + deps.queueSize.onCall(3).returns(39) + deps.queueSize.onCall(4).returns(40) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() // size=20: cross 1st threshold → fire + scheduler.notifyPushed() // size=21: must NOT fire + scheduler.notifyPushed() // size=22: must NOT fire + scheduler.notifyPushed() // size=39: must NOT fire + scheduler.notifyPushed() // size=40: cross 2nd threshold → fire + await flushMicrotasks() + + expect(deps.flush.callCount, 'threshold must fire only at 20 and 40, not on every push past 20').to.equal(2) + }) + + it('resets baseline when queue is drained below previous trigger size (auth transition)', async () => { + // M4.1 onAuthTransition drains the queue mirror. After a drain, the + // next 20-event crossing must fire again — without baseline reset, + // the comparison `size - lastTrigger` would go negative and stay + // sub-threshold forever after a login/logout cycle. + const deps = buildDeps({size: 5}) + deps.queueSize.onCall(0).returns(20) // 1st trigger + deps.queueSize.onCall(1).returns(0) // drain + deps.queueSize.onCall(2).returns(20) // re-built post-drain → must fire again + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + scheduler.notifyPushed() + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.callCount, 'drain must reset baseline so next 20 push fires again').to.equal(2) + }) + }) + + describe('idempotency (single-flight)', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('does NOT issue a second flush while one is already in flight (timer + threshold race)', async () => { + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 25}) + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + nextIntervalMs: () => 30_000, + thresholdCount: 20, + }) + scheduler.start() + + // Timer fires → flush (1) starts and stays pending. + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + // Threshold trip while flush-1 is in flight: setImmediate is faked + // so we tick once to drain it; the trigger must still be skipped. + scheduler.notifyPushed() + await clock.tickAsync(1) + expect(deps.flush.callCount, 'in-flight flush must skip new triggers').to.equal(1) + + // Another timer tick before flush-1 settles: also skipped. + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + // Settle flush-1. After settle, next trigger should run fresh. + releaseFlush() + await clock.tickAsync(0) + + // Grow the queue past the next threshold (25 → 45, delta 20). The + // post-fix notifyPushed gates on the DELTA since the last trigger, + // not the absolute size, so a follow-up call with the same size + // would correctly be a no-op. + deps.queueSize.returns(45) + scheduler.notifyPushed() + await clock.tickAsync(1) + expect(deps.flush.callCount, 'new trigger after settle must run').to.equal(2) + scheduler.stop() + }) + + it('continues to flush on the next interval after the in-flight settles', async () => { + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + releaseFlush() + await clock.tickAsync(0) + + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(2) + scheduler.stop() + }) + }) + + describe('flushFinal() for shutdown', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('returns the flush result when flush completes within the timeout', async () => { + const deps = buildDeps({async flushImpl() {}, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + + const promise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(1) + await promise + + expect(deps.flush.calledOnce).to.equal(true) + }) + + it('resolves after the timeout when flush takes too long (best-effort guarantee)', async () => { + const deps = buildDeps({flushImpl: neverResolvingFlush, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + + const promise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(3000) + await promise + + expect(deps.flush.calledOnce).to.equal(true) + }) + + it('skips flush entirely when the queue is empty', async () => { + const deps = buildDeps({size: 0}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + + await scheduler.flushFinal({timeoutMs: 3000}) + + expect(deps.flush.called, 'no flush on empty queue').to.equal(false) + }) + + it('skips flush when analytics is disabled', async () => { + const deps = buildDeps({enabled: false, size: 100}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + + await scheduler.flushFinal({timeoutMs: 3000}) + + expect(deps.flush.called).to.equal(false) + }) + + it('joins an in-flight flush rather than starting a second', async () => { + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + releaseFlush() + await finalPromise + + expect(deps.flush.callCount, 'final must join in-flight flush, not start a second').to.equal(1) + scheduler.stop() + }) + + it('joins a concurrent flush that claimed the slot mid-pendingCount (race regression)', async () => { + // Regression for the flushFinal double-send race: + // 1. flushFinal enters, sees pendingFlush=undefined. + // 2. flushFinal awaits pendingCount() (I/O). + // 3. During that await, a competing trigger (setImmediate from + // notifyPushed, or a last interval tick) calls startFlush and + // sets pendingFlush. + // 4. flushFinal resumes — without the double-check it would call + // startFlush again, overwrite the slot, and ship the same + // records twice. + // + // Reproducing the race deterministically requires forcing the + // tryFlush trigger to claim the slot BETWEEN flushFinal's + // pendingCount call and its post-await line. We do this by hooking + // a manually-released gate into `deps.pendingCount` and calling + // `tryFlush` (via the public threshold path) while flushFinal is + // parked on that gate. + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 20}) + + // Make pendingCount wait on a manual gate so the test can interleave + // a competing trigger before flushFinal resumes. + let releasePendingCount!: () => void + const pendingGate = new Promise((resolve) => { + releasePendingCount = resolve + }) + // First call (from flushFinal) waits on the gate; subsequent calls + // (from tryFlush triggered by notifyPushed) resolve immediately so + // the competing path can complete and claim pendingFlush. + let pendingCallCount = 0 + deps.pendingCount = sinon.stub().callsFake(async () => { + pendingCallCount += 1 + if (pendingCallCount === 1) await pendingGate + return 5 + }) + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + nextIntervalMs: () => 30_000, + thresholdCount: 20, + }) + + // Step A: flushFinal enters and parks on pendingCount. + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + + // Step B: trigger a competing tryFlush via the threshold path while + // flushFinal is still parked. notifyPushed schedules setImmediate; + // tickAsync(1) drains it and lets tryFlush call startFlush, which + // synchronously claims pendingFlush. + scheduler.notifyPushed() + await clock.tickAsync(1) + + // Step C: now release flushFinal's pendingCount gate. flushFinal + // resumes with pendingFlush ALREADY set by the competing tryFlush. + // The double-check must catch this and join instead of overwriting. + releasePendingCount() + releaseFlush() + await finalPromise + + expect(deps.flush.callCount, 'race regression: flushFinal must NOT start a second send').to.equal(1) + }) + + it('does NOT throw when the underlying flush rejects (analytics MUST NOT crash shutdown)', async () => { + const deps = buildDeps({async flushImpl() { throw new Error('network boom'); }, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) + + let threw = false + try { + const promise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(1) + await promise + } catch { + threw = true + } + + expect(threw, 'flushFinal must swallow flush rejections').to.equal(false) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts new file mode 100644 index 000000000..680a467e6 --- /dev/null +++ b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts @@ -0,0 +1,343 @@ +/* eslint-disable camelcase */ + +import {expect} from 'chai' +import nock from 'nock' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AxiosAnalyticsHttpClient} from '../../../../../src/server/infra/analytics/axios-analytics-http-client.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' +const baseUrl = 'https://telemetry-test.byterover.dev' + +function makeEvent(name = 'daemon_start') { + return { + created_at: '2023-11-14T22:13:20+00:00', + identity: {device_id: validDeviceId, user_id: 'user-123'}, + name, + properties: {cli_version: '3.12.0'}, + } +} + +function makeBatch(eventCount = 1): AnalyticsBatch { + return AnalyticsBatch.create(Array.from({length: eventCount}, (_, i) => makeEvent(`event_${String(i)}`))) +} + +describe('AxiosAnalyticsHttpClient', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + describe('happy path', () => { + it('POSTs the batch to /v1/events and returns ok=true on 2xx', async () => { + let receivedBody: unknown + const scope = nock(baseUrl) + .post('/v1/events', (body) => { + receivedBody = body + return true + }) + .matchHeader('x-byterover-device-id', validDeviceId) + .matchHeader('content-type', /application\/json/) + .matchHeader('user-agent', 'brv-cli/3.12.0') + .reply(200, {accepted: 1, rejected: 0}) + + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: true}) + expect(scope.isDone()).to.equal(true) + // Body matches the AnalyticsBatch.toJson() wire shape. + expect(receivedBody).to.have.property('schema_version', 2) + expect(receivedBody).to.have.nested.property('events.0.name', 'event_0') + }) + + it('stamps x-byterover-session-id when sessionId is provided', async () => { + const scope = nock(baseUrl) + .post('/v1/events') + .matchHeader('x-byterover-session-id', 'sess-abc') + .matchHeader('x-byterover-device-id', validDeviceId) + .reply(200, {}) + + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + sessionId: 'sess-abc', + userAgent: 'brv-cli/3.12.0', + }) + + expect(result.ok).to.equal(true) + expect(scope.isDone()).to.equal(true) + }) + + it('does NOT send an authorization header (analytics works anonymous)', async () => { + let recordedHeaders: Record | undefined + const scope = nock(baseUrl) + .post('/v1/events') + .reply(function () { + recordedHeaders = this.req.headers + return [200, {}] + }) + + const client = new AxiosAnalyticsHttpClient({baseUrl}) + await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(scope.isDone()).to.equal(true) + expect(recordedHeaders).to.not.have.property('authorization') + }) + }) + + describe('failure classification', () => { + it('returns ok=false reason=http_4xx with status for a 400', async () => { + nock(baseUrl).post('/v1/events').reply(400, {message: 'bad request'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: false, reason: 'http_4xx', status: 400}) + }) + + it('returns ok=false reason=http_5xx with status for a 500 (non-503 server errors stay transient)', async () => { + nock(baseUrl).post('/v1/events').reply(500, {message: 'boom'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: false, reason: 'http_5xx', status: 500}) + }) + + it('returns ok=false reason=network when the connection cannot be established', async () => { + // Point axios at an unreachable port; nock + disableNetConnect would + // surface the same network-level failure but with timing variance + // across CI runs. Targeting localhost:1 yields a deterministic + // connect refusal that axios classifies as a non-response error + // (not a timeout, since the request never enters the timeout window). + nock.enableNetConnect('127.0.0.1') + const client = new AxiosAnalyticsHttpClient({baseUrl: 'http://127.0.0.1:1'}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).to.equal('network') + nock.disableNetConnect() + }) + + it('returns ok=false reason=timeout when the server is too slow', async () => { + // 100ms timeout for the test; nock delay > timeout to force ETIMEDOUT. + nock(baseUrl).post('/v1/events').delay(500).reply(200, {}) + const client = new AxiosAnalyticsHttpClient({baseUrl, timeoutMs: 100}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).to.equal('timeout') + }) + }) + + describe('rate-limit classification (M5.4 honor Retry-After — ENG-2658)', () => { + it('429 with a Retry-After header returns rate_limited + retryAfterMs from the header', async () => { + nock(baseUrl).post('/v1/events').reply(429, {message: 'slow down'}, {'retry-after': '30'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 30_000, status: 429}) + }) + + it('429 with no header but a retry_after_seconds body falls back to the body value', async () => { + nock(baseUrl).post('/v1/events').reply(429, {retry_after_seconds: 30}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 30_000, status: 429}) + }) + + it('429 with neither header nor body falls back to a 60s default and logs a WARN', async () => { + nock(baseUrl).post('/v1/events').reply(429, {message: 'too many requests'}) + const logged: string[] = [] + const client = new AxiosAnalyticsHttpClient({baseUrl, log: (m) => logged.push(m)}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 60_000, status: 429}) + expect( + logged.some((m) => m.includes('429') && /default/i.test(m)), + 'a WARN is logged when falling back to the default delay', + ).to.equal(true) + }) + + it('503 from the edge backstop returns rate_limited with the default delay and logs a WARN (not unreachable)', async () => { + nock(baseUrl).post('/v1/events').reply(503, {message: 'unavailable'}) + const logged: string[] = [] + const client = new AxiosAnalyticsHttpClient({baseUrl, log: (m) => logged.push(m)}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 60_000, status: 503}) + expect(logged.some((m) => m.includes('503')), 'a WARN is logged for the 503 edge backstop').to.equal(true) + }) + + it('503 WITH a Retry-After header honors it instead of forcing the 60s default (PR #743 review)', async () => { + nock(baseUrl).post('/v1/events').reply(503, {}, {'retry-after': '45'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 45_000, status: 503}) + }) + + it('429 with a future HTTP-date Retry-After converts to a forward-looking delay (RFC 7231)', async () => { + const aboutTwoMin = new Date(Date.now() + 120_000).toUTCString() + nock(baseUrl).post('/v1/events').reply(429, {}, {'retry-after': aboutTwoMin}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + if (result.ok || result.reason !== 'rate_limited') throw new Error('expected a rate_limited result') + // ~120s, allowing tolerance for second-truncation + elapsed time during the call. + expect(result.retryAfterMs).to.be.greaterThan(110_000) + expect(result.retryAfterMs).to.be.at.most(120_000) + expect(result.status).to.equal(429) + }) + + it('429 with an HTTP-date Retry-After already in the past falls back to the 60s default', async () => { + const past = new Date(Date.now() - 60_000).toUTCString() + nock(baseUrl).post('/v1/events').reply(429, {}, {'retry-after': past}) + const logged: string[] = [] + const client = new AxiosAnalyticsHttpClient({baseUrl, log: (m) => logged.push(m)}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 60_000, status: 429}) + expect(logged.some((m) => /default/i.test(m)), 'a past date is no usable hint -> default + WARN').to.equal(true) + }) + }) + + describe('contract guarantees', () => { + it('does NOT throw on any failure path', async () => { + // Combine the slowest failure mode (timeout) with a tight client + // budget so the assertion completes in <200ms instead of the + // default 5s. The point is to prove the catch path returns a + // tagged result rather than propagating an exception. + nock(baseUrl).post('/v1/events').delay(400).reply(500, {}) + const client = new AxiosAnalyticsHttpClient({baseUrl, timeoutMs: 100}) + + let threw = false + try { + await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + } catch { + threw = true + } + + expect(threw, 'send() must never throw').to.equal(false) + }) + + it('sends the full batch body unchanged (round-trips through AnalyticsBatch.fromJson)', async () => { + let receivedBody: unknown + nock(baseUrl) + .post('/v1/events', (body) => { + receivedBody = body + return true + }) + .reply(200, {}) + + const batch = makeBatch(3) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + await client.send(batch, {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + const restored = AnalyticsBatch.fromJson(receivedBody) + expect(restored, 'wire body must parse back as AnalyticsBatch').to.not.equal(undefined) + expect(restored?.events).to.have.lengthOf(3) + }) + }) + + describe('abort support (M4.4)', () => { + it('returns ok=false reason=network when the signal is aborted mid-flight', async () => { + // Server takes 500ms to reply; we abort after the request is in + // flight. Without abort plumbing the client would wait the full + // 5s timeout and the test would slow the suite. + nock(baseUrl).post('/v1/events').delay(500).reply(200, {}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const controller = new AbortController() + + const sendPromise = client.send( + makeBatch(1), + {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}, + {signal: controller.signal}, + ) + // Give axios a tick to dispatch the request, then abort. + await new Promise((resolve) => { + setTimeout(resolve, 20) + }) + controller.abort() + const result = await sendPromise + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + // Aborted requests classify as `network` (not `timeout`) — they + // were terminated client-side, the server never replied. + expect(result.reason).to.equal('network') + }) + + it('returns ok=false reason=network when the signal is already aborted before send', async () => { + // No nock interceptor — if axios honored the pre-aborted signal it + // never hits the network. If it didn't, this would 503 with + // "Nock: No match" and fail the assertion below. + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const controller = new AbortController() + controller.abort() + + const result = await client.send( + makeBatch(1), + {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}, + {signal: controller.signal}, + ) + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).to.equal('network') + }) + + it('completes normally when an unaborted signal is passed', async () => { + nock(baseUrl).post('/v1/events').reply(200, {accepted: 1}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const controller = new AbortController() // never abort() + + const result = await client.send( + makeBatch(1), + {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}, + {signal: controller.signal}, + ) + + expect(result.ok, 'unaborted signal must not block a healthy send').to.equal(true) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/bounded-queue.test.ts b/test/unit/server/infra/analytics/bounded-queue.test.ts new file mode 100644 index 000000000..c271e5c92 --- /dev/null +++ b/test/unit/server/infra/analytics/bounded-queue.test.ts @@ -0,0 +1,191 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' + +let eventCounter = 0 +function makeEvent(name: string): StoredAnalyticsRecord { + eventCounter += 1 + return { + attempts: 0, + id: `id-${eventCounter}`, + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000'}, + name, + properties: {}, + status: 'pending', + timestamp: 0, + } +} + +function pushAll(queue: BoundedQueue, events: StoredAnalyticsRecord[]): void { + for (const event of events) { + queue.push(event) + } +} + +describe('BoundedQueue', () => { + describe('constructor validation', () => { + it('should throw when maxSize is negative', () => { + expect(() => new BoundedQueue(-1)).to.throw(/non-negative/) + }) + + it('should throw when maxSize is NaN', () => { + expect(() => new BoundedQueue(Number.NaN)).to.throw(/non-negative/) + }) + + it('should throw when maxSize is Infinity', () => { + expect(() => new BoundedQueue(Number.POSITIVE_INFINITY)).to.throw(/non-negative/) + }) + + it('should throw when maxSize is fractional', () => { + expect(() => new BoundedQueue(1.5)).to.throw(/non-negative/) + }) + + it('should accept maxSize === 0 (degenerate but valid)', () => { + expect(() => new BoundedQueue(0)).to.not.throw() + }) + }) + + describe('basic FIFO behavior (ticket scenario 1)', () => { + it('should return pushed events in FIFO order on drain', () => { + const queue = new BoundedQueue(10) + const eventA = makeEvent('a') + const eventB = makeEvent('b') + const eventC = makeEvent('c') + + pushAll(queue, [eventA, eventB, eventC]) + + const drained = queue.drain() + + expect(drained).to.deep.equal([eventA, eventB, eventC]) + }) + }) + + describe('empty queue (ticket scenario 2)', () => { + it('should return [] on drain when empty', () => { + const queue = new BoundedQueue(10) + + expect(queue.drain()).to.deep.equal([]) + }) + + it('should return 0 from droppedCount() when empty', () => { + const queue = new BoundedQueue(10) + + expect(queue.droppedCount()).to.equal(0) + }) + + it('should return 0 from size() when empty', () => { + const queue = new BoundedQueue(10) + + expect(queue.size()).to.equal(0) + }) + }) + + describe('drop-oldest semantics (ticket scenario 3)', () => { + it('should drop the oldest event when pushing beyond maxSize', () => { + const queue = new BoundedQueue(3) + const events = [makeEvent('a'), makeEvent('b'), makeEvent('c'), makeEvent('d')] + + for (const event of events) { + queue.push(event) + } + + const drained = queue.drain() + + expect(drained).to.have.lengthOf(3) + expect(drained[0].name).to.equal('b') + expect(drained[1].name).to.equal('c') + expect(drained[2].name).to.equal('d') + expect(queue.droppedCount()).to.equal(1) + }) + + it('should track multiple drops in FIFO drop order', () => { + const queue = new BoundedQueue(2) + + pushAll(queue, ['a', 'b', 'c', 'd', 'e'].map((n) => makeEvent(n))) + + const drained = queue.drain() + + expect(drained.map((event) => event.name)).to.deep.equal(['d', 'e']) + expect(queue.droppedCount()).to.equal(3) + }) + }) + + describe('cumulative droppedCount (ticket scenario 4)', () => { + it('should not reset droppedCount across drains', () => { + const queue = new BoundedQueue(2) + + pushAll(queue, ['a', 'b', 'c'].map((n) => makeEvent(n))) + expect(queue.droppedCount()).to.equal(1) + + queue.drain() + expect(queue.droppedCount(), 'drain must NOT reset droppedCount').to.equal(1) + + pushAll(queue, ['d', 'e', 'f'].map((n) => makeEvent(n))) + expect(queue.droppedCount()).to.equal(2) + + queue.drain() + expect(queue.droppedCount()).to.equal(2) + }) + }) + + describe('size() (ticket scenario 5)', () => { + it('should reflect current queue length', () => { + const queue = new BoundedQueue(10) + + expect(queue.size()).to.equal(0) + queue.push(makeEvent('a')) + expect(queue.size()).to.equal(1) + queue.push(makeEvent('b')) + expect(queue.size()).to.equal(2) + }) + + it('should return zero after drain', () => { + const queue = new BoundedQueue(10) + pushAll(queue, [makeEvent('a'), makeEvent('b')]) + + queue.drain() + + expect(queue.size()).to.equal(0) + }) + + it('should never exceed maxSize after pushes', () => { + const queue = new BoundedQueue(3) + + for (let i = 0; i < 10; i++) { + queue.push(makeEvent(`event_${i}`)) + } + + expect(queue.size()).to.equal(3) + }) + }) + + describe('default maxSize (ticket scenario 6)', () => { + it('should default to 1000 and drop 1 when 1001 events are pushed', () => { + const queue = new BoundedQueue() + + for (let i = 0; i < 1001; i++) { + queue.push(makeEvent(`event_${i}`)) + } + + expect(queue.size()).to.equal(1000) + expect(queue.droppedCount()).to.equal(1) + }) + }) + + describe('drain ownership transfer', () => { + it('should return a fresh empty queue after drain (caller owns drained events)', () => { + const queue = new BoundedQueue(10) + pushAll(queue, [makeEvent('a'), makeEvent('b')]) + + const firstDrain = queue.drain() + queue.push(makeEvent('c')) + const secondDrain = queue.drain() + + expect(firstDrain.map((event) => event.name)).to.deep.equal(['a', 'b']) + expect(secondDrain.map((event) => event.name)).to.deep.equal(['c']) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/build-status-snapshot.test.ts b/test/unit/server/infra/analytics/build-status-snapshot.test.ts new file mode 100644 index 000000000..6c28d6701 --- /dev/null +++ b/test/unit/server/infra/analytics/build-status-snapshot.test.ts @@ -0,0 +1,131 @@ +import {expect} from 'chai' + +import type {IAnalyticsBackoffPolicy} from '../../../../../src/server/core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {buildAnalyticsStatusSnapshot} from '../../../../../src/server/infra/analytics/build-status-snapshot.js' + +type RuntimeStateSnapshot = { + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number +} + +function makeClientStub(state: RuntimeStateSnapshot): IAnalyticsClient { + return { + abort() {}, + flush: async () => AnalyticsBatch.create([]), + getRuntimeState: async () => state, + async onAuthTransition() {}, + track() {}, + } +} + +function makePolicyStub( + consecutiveFailures: number, + nextDelayMs: number, + isRateLimited = false, +): IAnalyticsBackoffPolicy { + return { + applyServerHint() {}, + consecutiveFailures: () => consecutiveFailures, + isRateLimited: () => isRateLimited, + nextDelayMs: () => nextDelayMs, + onFailure() {}, + onSuccess() {}, + } +} + +describe('buildAnalyticsStatusSnapshot (M16.3)', () => { + it('composes the wire response with all fields populated', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 4}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot).to.deep.equal({ + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: 1_700_000_000_000, + queueDepth: 4, + }) + }) + + it('substitutes the (not configured) placeholder and forces unreachable when endpoint is empty', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: '', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.endpoint).to.equal('(not configured)') + expect(snapshot.backoff.state).to.equal('unreachable') + }) + + it('omits lastFlushAt when the daemon has never shipped', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 2}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.lastFlushAt).to.equal(undefined) + }) + + it('preserves the disabled flag without dropping operational fields', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 7, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 3}), + backoffPolicy: makePolicyStub(1, 60_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => false, + }) + + expect(snapshot.enabled).to.equal(false) + expect(snapshot.queueDepth).to.equal(3) + expect(snapshot.droppedCount).to.equal(7) + expect(snapshot.lastFlushAt).to.equal(1_700_000_000_000) + expect(snapshot.backoff).to.deep.equal({consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}) + }) + + it('maps 1-2 consecutive failures to degraded', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(2, 120_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.backoff.state).to.equal('degraded') + }) + + it('maps 3+ consecutive failures to unreachable', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 5}), + backoffPolicy: makePolicyStub(5, 300_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.backoff.state).to.equal('unreachable') + }) + + it('maps a rate-limited policy to the distinct rate_limited state, even at 0 failures (M5.4 — ENG-2658)', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 1}), + // 0 consecutive failures (429s never bump the counter) but rate-limited: + // must surface as `rate_limited`, NOT `healthy` and NOT `unreachable`. + backoffPolicy: makePolicyStub(0, 60_000, true), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.backoff.state).to.equal('rate_limited') + }) +}) diff --git a/test/unit/server/infra/analytics/draining-analytics-sender.test.ts b/test/unit/server/infra/analytics/draining-analytics-sender.test.ts new file mode 100644 index 000000000..6cd6169f5 --- /dev/null +++ b/test/unit/server/infra/analytics/draining-analytics-sender.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {DrainingAnalyticsSender} from '../../../../../src/server/infra/analytics/draining-analytics-sender.js' + +function makeRecord(id: string): StoredAnalyticsRecord { + return { + attempts: 0, + id, + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000', user_id: 'user-123'}, + name: 'daemon_start', + properties: {cli_version: '3.12.0'}, + status: 'pending', + timestamp: 1_700_000_000_000, + } satisfies StoredAnalyticsRecord +} + +describe('DrainingAnalyticsSender (graceful-degradation sender)', () => { + it('marks every input id as succeeded so JSONL drains', async () => { + const sender = new DrainingAnalyticsSender() + const result = await sender.send([makeRecord('a'), makeRecord('b'), makeRecord('c')]) + expect(result).to.deep.equal({failed: [], succeeded: ['a', 'b', 'c']}) + }) + + it('returns empty arrays for an empty batch', async () => { + const sender = new DrainingAnalyticsSender() + const result = await sender.send([]) + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('ignores the AbortSignal option and never throws', async () => { + const sender = new DrainingAnalyticsSender() + const controller = new AbortController() + controller.abort() + const result = await sender.send([makeRecord('a')], {signal: controller.signal}) + expect(result.succeeded).to.deep.equal(['a']) + expect(result.failed).to.deep.equal([]) + }) + + it('does not invoke any collaborator (no deps to inject means none can be touched)', async () => { + // Structural assertion: DrainingAnalyticsSender has a zero-arg constructor. + // If a future refactor introduces deps, this no-arg construction line + // would fail to type-check, surfacing the regression at compile time. + const sender = new DrainingAnalyticsSender() + expect(sender).to.be.an.instanceOf(DrainingAnalyticsSender) + }) +}) diff --git a/test/unit/server/infra/analytics/http-analytics-sender.test.ts b/test/unit/server/infra/analytics/http-analytics-sender.test.ts new file mode 100644 index 000000000..8ddefae0f --- /dev/null +++ b/test/unit/server/infra/analytics/http-analytics-sender.test.ts @@ -0,0 +1,298 @@ +/* eslint-disable camelcase */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import type {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import type { + AnalyticsHttpHeaders, + AnalyticsHttpSendResult, + IAnalyticsHttpClient, +} from '../../../../../src/server/core/interfaces/analytics/i-analytics-http-client.js' +import type {IAuthStateReader} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {HttpAnalyticsSender} from '../../../../../src/server/infra/analytics/http-analytics-sender.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + created_at: '2023-11-14T22:13:20+00:00', + id: overrides.id ?? '11111111-1111-1111-1111-111111111111', + identity: {device_id: validDeviceId, user_id: 'user-123'}, + name: 'daemon_start', + properties: {cli_version: '3.12.0'}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +function makeStubConfigStore(deviceId: string = validDeviceId): IGlobalConfigStore { + const config = GlobalConfig.fromJson({analytics: true, deviceId, version: '0.0.1'}) + if (!config) throw new Error('fixture: GlobalConfig.fromJson must succeed') + return {read: stub().resolves(config), write: stub().resolves()} +} + +function makeAuthReader(token?: AuthToken): IAuthStateReader { + return {getToken: () => token} +} + +type RecordedSend = {batch: AnalyticsBatch; headers: AnalyticsHttpHeaders} + +type CapturingHttpClient = IAnalyticsHttpClient & {readonly calls: RecordedSend[]} + +function makeCapturingHttpClient(result: AnalyticsHttpSendResult): CapturingHttpClient { + const calls: RecordedSend[] = [] + return { + calls, + async send(batch, headers) { + calls.push({batch, headers}) + return result + }, + } +} + +describe('HttpAnalyticsSender', () => { + describe('happy path', () => { + it('sends a batch built from the input records and returns succeeded ids', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const r1 = makeRecord({id: 'r1', name: 'event_a'}) + const r2 = makeRecord({id: 'r2', name: 'event_b'}) + const result = await sender.send([r1, r2]) + + expect(result).to.deep.equal({failed: [], succeeded: ['r1', 'r2']}) + expect(httpClient.calls).to.have.lengthOf(1) + const [{batch}] = httpClient.calls + expect(batch.events).to.have.lengthOf(2) + expect(batch.events[0].name).to.equal('event_a') + expect(batch.events[1].name).to.equal('event_b') + }) + + it('stamps deviceId from GlobalConfig + userAgent from the constructor', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore('dev-from-config'), + httpClient, + userAgent: 'brv-cli/9.9.9', + }) + + await sender.send([makeRecord()]) + + const [{headers}] = httpClient.calls + expect(headers.deviceId).to.equal('dev-from-config') + expect(headers.userAgent).to.equal('brv-cli/9.9.9') + }) + + it('stamps sessionId from AuthStateReader when authenticated', async () => { + const token = {sessionKey: 'sess-abc'} as AuthToken + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(token), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + await sender.send([makeRecord()]) + + const [{headers}] = httpClient.calls + expect(headers.sessionId).to.equal('sess-abc') + }) + + it('omits sessionId when anonymous (no auth token)', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + await sender.send([makeRecord()]) + + const [{headers}] = httpClient.calls + expect(headers.sessionId).to.equal(undefined) + }) + }) + + describe('empty input', () => { + it('returns empty result without calling the http client for an empty batch', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + expect(httpClient.calls).to.have.lengthOf(0) + }) + }) + + describe('failure mapping', () => { + it('returns all ids as failed when http client reports http_5xx (carries reason for M4.5 backoff)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'http_5xx', status: 503}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const r1 = makeRecord({id: 'r1'}) + const r2 = makeRecord({id: 'r2'}) + const result = await sender.send([r1, r2]) + + expect(result).to.deep.equal({failed: ['r1', 'r2'], reason: 'http_5xx', succeeded: []}) + }) + + it('returns all ids as failed when http client reports timeout (carries reason)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'timeout'}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'only'})]) + + expect(result).to.deep.equal({failed: ['only'], reason: 'timeout', succeeded: []}) + }) + + it('returns all ids as failed when http client reports network failure (carries reason)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'network'}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'only'})]) + + expect(result).to.deep.equal({failed: ['only'], reason: 'network', succeeded: []}) + }) + + it('returns http_4xx reason verbatim (caller uses this to decide NOT to advance backoff)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'http_4xx', status: 400}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'only'})]) + + expect(result).to.deep.equal({failed: ['only'], reason: 'http_4xx', succeeded: []}) + }) + + it('forwards rate_limited reason AND the retryAfterMs hint (M5.4 — ENG-2658)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'rate_limited', retryAfterMs: 30_000, status: 429}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'}), makeRecord({id: 'r2'})]) + + expect(result).to.deep.equal({ + failed: ['r1', 'r2'], + reason: 'rate_limited', + retryAfterMs: 30_000, + succeeded: [], + }) + }) + + it('does NOT set reason on a successful send (M4.5 caller treats absence as success)', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: [], succeeded: ['r1']}) + expect(Object.hasOwn(result, 'reason'), 'reason key must be absent on success').to.equal(false) + }) + }) + + describe('crash safety', () => { + it('does NOT throw if globalConfigStore.read() rejects; treats the batch as failed', async () => { + const failingStore: IGlobalConfigStore = { + read: stub().rejects(new Error('disk full')), + write: stub().resolves(), + } + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: failingStore, + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + let threw = false + let result + try { + result = await sender.send([makeRecord({id: 'r1'})]) + } catch { + threw = true + } + + expect(threw, 'sender must NOT throw on collaborator failure').to.equal(false) + // M4.5: collaborator throws are tagged `network` so the M4.5 + // backoff treats them as transient (caller will advance backoff). + expect(result).to.deep.equal({failed: ['r1'], reason: 'network', succeeded: []}) + expect(httpClient.calls).to.have.lengthOf(0) + }) + + it('treats missing deviceId as an http_4xx failure (anonymous batches still need a device id per backend contract)', async () => { + // GlobalConfigStore returns undefined (first-run before the daemon + // has provisioned a device id). Per the backend contract, batches + // without `x-byterover-device-id` are 400-rejected; the sender refuses + // to ship and classifies the failure as `http_4xx` (a payload-shape + // problem, not a transient signal) so the M4.5 backoff policy does not + // churn on a daemon-side misconfig, while the flush mirror (M10.2) + // still increments attempts toward the retry cap. + const emptyStore: IGlobalConfigStore = { + read: stub().resolves(), + write: stub().resolves(), + } + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: emptyStore, + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: ['r1'], reason: 'http_4xx', succeeded: []}) + expect(httpClient.calls).to.have.lengthOf(0) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/identity-resolver.test.ts b/test/unit/server/infra/analytics/identity-resolver.test.ts new file mode 100644 index 000000000..56f6dc508 --- /dev/null +++ b/test/unit/server/infra/analytics/identity-resolver.test.ts @@ -0,0 +1,229 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IAuthStateReader} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {IdentityResolver} from '../../../../../src/server/infra/analytics/identity-resolver.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeStubStore(deviceId: string = validDeviceId, configPresent = true): IGlobalConfigStore { + if (!configPresent) { + return { + read: stub().resolves(), + write: stub().resolves(), + } + } + + const config = GlobalConfig.fromJson({ + analytics: false, + deviceId, + version: '0.0.1', + }) + if (!config) { + throw new Error('test fixture: GlobalConfig.fromJson must succeed') + } + + return { + read: stub().resolves(config), + write: stub().resolves(), + } +} + +function makeAuthReader(token?: AuthToken): IAuthStateReader { + return {getToken: () => token} +} + +function makeMutableAuthReader(): {reader: IAuthStateReader; setToken: (t: AuthToken | undefined) => void} { + let currentToken: AuthToken | undefined + return { + reader: {getToken: () => currentToken}, + setToken(t) { + currentToken = t + }, + } +} + +function makeFullToken(): AuthToken { + return new AuthToken({ + accessToken: 'access-abc', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-xyz', + sessionKey: 'session-key', + userEmail: 'alice@example.com', + userId: 'user-123', + userName: 'Alice', + }) +} + +function makeTokenWithEmpty(opts: {userEmail?: string; userName?: string}): AuthToken { + return new AuthToken({ + accessToken: 'access-abc', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-xyz', + sessionKey: 'session-key', + userEmail: opts.userEmail ?? 'alice@example.com', + userId: 'user-123', + userName: opts.userName, + }) +} + +describe('IdentityResolver', () => { + describe('anonymous (ticket scenario 1)', () => { + it('should return only device_id when no auth token is present', async () => { + const resolver = new IdentityResolver(makeAuthReader(), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.deep.equal({device_id: validDeviceId}) + expect(identity).to.not.have.property('user_id') + expect(identity).to.not.have.property('email') + expect(identity).to.not.have.property('name') + }) + }) + + describe('registered with full user (ticket scenario 2)', () => { + it('should return user_id, email, name, and device_id', async () => { + const resolver = new IdentityResolver(makeAuthReader(makeFullToken()), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.deep.equal({ + device_id: validDeviceId, + email: 'alice@example.com', + name: 'Alice', + user_id: 'user-123', + }) + }) + }) + + describe('registered with empty userEmail (ticket scenario 3)', () => { + it('should omit email property entirely (not present as undefined)', async () => { + const token = makeTokenWithEmpty({userEmail: ''}) + const resolver = new IdentityResolver(makeAuthReader(token), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.not.have.property('email') + expect(identity.user_id).to.equal('user-123') + expect(identity.device_id).to.equal(validDeviceId) + }) + }) + + describe('auth state transitions (ticket scenarios 4 + 5)', () => { + it('should pick up the new identity when transitioning anonymous → registered', async () => { + const {reader, setToken} = makeMutableAuthReader() + const resolver = new IdentityResolver(reader, makeStubStore()) + + const first = await resolver.resolve() + expect(first).to.deep.equal({device_id: validDeviceId}) + + setToken(makeFullToken()) + const second = await resolver.resolve() + + expect(second).to.deep.equal({ + device_id: validDeviceId, + email: 'alice@example.com', + name: 'Alice', + user_id: 'user-123', + }) + }) + + it('should pick up anonymous when transitioning registered → anonymous', async () => { + const {reader, setToken} = makeMutableAuthReader() + setToken(makeFullToken()) + const resolver = new IdentityResolver(reader, makeStubStore()) + + const first = await resolver.resolve() + expect(first.user_id).to.equal('user-123') + + setToken(undefined) + const second = await resolver.resolve() + + expect(second).to.deep.equal({device_id: validDeviceId}) + expect(second).to.not.have.property('user_id') + }) + }) + + describe('device_id always present (ticket scenario 6)', () => { + it('should include device_id when registered', async () => { + const resolver = new IdentityResolver(makeAuthReader(makeFullToken()), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity.device_id).to.equal(validDeviceId) + }) + + it('should include device_id when anonymous', async () => { + const resolver = new IdentityResolver(makeAuthReader(), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity.device_id).to.equal(validDeviceId) + }) + }) + + describe('empty userName (bonus)', () => { + it('should omit name property entirely when userName is missing', async () => { + const token = makeTokenWithEmpty({userName: undefined}) + const resolver = new IdentityResolver(makeAuthReader(token), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.not.have.property('name') + expect(identity.user_id).to.equal('user-123') + }) + }) + + describe('missing GlobalConfig (bonus)', () => { + it("should default device_id to '' when the store returns undefined", async () => { + const resolver = new IdentityResolver(makeAuthReader(), makeStubStore('', false)) + + const identity = await resolver.resolve() + + expect(identity).to.deep.equal({device_id: ''}) + }) + }) + + // M4.1 regression: identity returned by resolve() is a snapshot, not a live view. + // Subsequent auth-state mutations must not retroactively rewrite a previously + // resolved Identity. The downstream queue stores these objects by reference, + // so an accidental shared/mutated state would corrupt already-tracked events + // when a user logs out before the flush completes. + describe('M4.1 identity captured by value (snapshot semantics)', () => { + it('should not mutate a previously resolved identity when the token reader later returns undefined', async () => { + const {reader, setToken} = makeMutableAuthReader() + setToken(makeFullToken()) + const resolver = new IdentityResolver(reader, makeStubStore()) + + const firstResolved = await resolver.resolve() + const snapshot = {...firstResolved} + + setToken(undefined) + const second = await resolver.resolve() + expect(second).to.deep.equal({device_id: validDeviceId}) + + // First object must be byte-equivalent to the snapshot taken before the mutation. + expect(firstResolved).to.deep.equal(snapshot) + expect(firstResolved.user_id).to.equal('user-123') + expect(firstResolved.email).to.equal('alice@example.com') + expect(firstResolved.name).to.equal('Alice') + }) + + it('should not share references between two resolve() calls (each returns a fresh object)', async () => { + const {reader, setToken} = makeMutableAuthReader() + setToken(makeFullToken()) + const resolver = new IdentityResolver(reader, makeStubStore()) + + const first = await resolver.resolve() + const second = await resolver.resolve() + + expect(first).to.deep.equal(second) + expect(first).to.not.equal(second) // distinct object references — no shared mutable state + }) + }) +}) diff --git a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts new file mode 100644 index 000000000..f365cc5fd --- /dev/null +++ b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts @@ -0,0 +1,664 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {mkdir, readFile, stat, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {JsonlAnalyticsStore, JsonlCapFullError} from '../../../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../../../src/shared/analytics/stored-record.js' + +const validIdentity = { + device_id: '550e8400-e29b-41d4-a716-446655440000', +} + +async function freshTempDir(): Promise { + const dir = join(tmpdir(), `jsonl-store-${randomUUID()}`) + await mkdir(dir, {recursive: true}) + return dir +} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: randomUUID(), + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: Date.now(), + ...overrides, + } +} + +async function readJsonlRows(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8') + const records: StoredAnalyticsRecord[] = [] + for (const line of content.split('\n')) { + if (line.length === 0) continue + const parsed = StoredAnalyticsRecordSchema.parse(JSON.parse(line)) + records.push(parsed) + } + + return records + } catch { + return [] + } +} + +function findRow(rows: StoredAnalyticsRecord[], id: string): StoredAnalyticsRecord { + const row = rows.find((r) => r.id === id) + if (row === undefined) throw new Error(`expected row with id=${id}`) + return row +} + +describe('JsonlAnalyticsStore', () => { + describe('append()', () => { + it('should write one row plus newline to a fresh file', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + const record = makeRecord({id: 'rec-1', name: 'event-a'}) + + await store.append(record) + + const filePath = join(baseDir, 'analytics-queue.jsonl') + const content = await readFile(filePath, 'utf8') + expect(content.endsWith('\n')).to.equal(true) + expect(content.split('\n').filter((l) => l.length > 0)).to.have.lengthOf(1) + const rows = await readJsonlRows(filePath) + expect(rows[0].id).to.equal('rec-1') + expect(rows[0].name).to.equal('event-a') + }) + + it('should append multiple rows in arrival order', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows.map((r) => r.id)).to.deep.equal(['r1', 'r2', 'r3']) + }) + + it('should create the base directory if it does not exist', async () => { + const parent = await freshTempDir() + const baseDir = join(parent, 'nested', 'path') + const store = new JsonlAnalyticsStore({baseDir}) + + await store.append(makeRecord({id: 'r1'})) + + const stats = await stat(join(baseDir, 'analytics-queue.jsonl')) + expect(stats.isFile()).to.equal(true) + }) + }) + + describe("updateStatus(ids, 'sent')", () => { + it('should flip status to sent and leave attempts unchanged', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 1, id: 'r1', status: 'pending'})) + + await store.updateStatus(['r1'], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('sent') + expect(rows[0].attempts).to.equal(1) + }) + + it('should leave other rows untouched', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + await store.updateStatus(['r2'], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(findRow(rows, 'r1').status).to.equal('pending') + expect(findRow(rows, 'r2').status).to.equal('sent') + expect(findRow(rows, 'r3').status).to.equal('pending') + }) + + it('should be no-op for empty ids array', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + await store.updateStatus([], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + }) + + it('should be no-op for non-matching ids', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + await store.updateStatus(['does-not-exist'], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + }) + }) + + describe("updateStatus(ids, 'failed') retry-cap policy", () => { + it('should keep status pending after first failure (attempts=1)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + + await store.updateStatus(['r1'], 'failed') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + expect(rows[0].attempts).to.equal(1) + }) + + it('should still be pending after second failure (attempts=2)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + + await store.updateStatus(['r1'], 'failed') + await store.updateStatus(['r1'], 'failed') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + expect(rows[0].attempts).to.equal(2) + }) + + it(`should transition to terminal 'failed' at MAX_ATTEMPTS (${MAX_ATTEMPTS})`, async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await store.updateStatus(['r1'], 'failed') + } + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('failed') + expect(rows[0].attempts).to.equal(MAX_ATTEMPTS) + }) + + it("should be no-op on a row already at terminal 'failed' (no overshoot)", async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await store.updateStatus(['r1'], 'failed') + } + + await store.updateStatus(['r1'], 'failed') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('failed') + expect(rows[0].attempts).to.equal(MAX_ATTEMPTS) + }) + }) + + describe('list()', () => { + it('should return empty result when file does not exist', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + const result = await store.list({limit: 10, offset: 0}) + + expect(result.rows).to.deep.equal([]) + expect(result.total).to.equal(0) + }) + + it('should paginate via offset and limit', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + for (let i = 0; i < 10; i++) { + // eslint-disable-next-line no-await-in-loop + await store.append(makeRecord({id: `r${i}`, timestamp: i})) + } + + const page1 = await store.list({limit: 3, offset: 0}) + const page2 = await store.list({limit: 3, offset: 3}) + + expect(page1.rows).to.have.lengthOf(3) + expect(page2.rows).to.have.lengthOf(3) + expect(page1.total).to.equal(10) + expect(page2.total).to.equal(10) + const ids = [...page1.rows.map((r) => r.id), ...page2.rows.map((r) => r.id)] + expect(new Set(ids).size).to.equal(6) // no duplicates between pages + }) + + it('should filter by eventName', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1', name: 'event-a'})) + await store.append(makeRecord({id: 'r2', name: 'event-b'})) + await store.append(makeRecord({id: 'r3', name: 'event-a'})) + + const result = await store.list({eventName: 'event-a', limit: 10, offset: 0}) + + expect(result.total).to.equal(2) + expect(result.rows.map((r) => r.id).sort()).to.deep.equal(['r1', 'r3']) + }) + + it('should filter by status', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + await store.updateStatus(['r2'], 'sent') + + const pending = await store.list({limit: 10, offset: 0, status: 'pending'}) + const sent = await store.list({limit: 10, offset: 0, status: 'sent'}) + + expect(pending.total).to.equal(2) + expect(sent.total).to.equal(1) + expect(sent.rows[0].id).to.equal('r2') + }) + + it('should filter by both eventName and status', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1', name: 'event-a'})) + await store.append(makeRecord({id: 'r2', name: 'event-b'})) + await store.append(makeRecord({id: 'r3', name: 'event-a'})) + await store.updateStatus(['r1'], 'sent') + + const result = await store.list({eventName: 'event-a', limit: 10, offset: 0, status: 'sent'}) + + expect(result.total).to.equal(1) + expect(result.rows[0].id).to.equal('r1') + }) + + it('should sort by (timestamp DESC, id DESC) for stable ordering on same-timestamp', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'aaa', timestamp: 100})) + await store.append(makeRecord({id: 'bbb', timestamp: 200})) + await store.append(makeRecord({id: 'ccc', timestamp: 100})) + + const result = await store.list({limit: 10, offset: 0}) + + // Newest timestamp first; same timestamp tie broken by id DESC + expect(result.rows.map((r) => r.id)).to.deep.equal(['bbb', 'ccc', 'aaa']) + }) + + it('should return correct total post-filter when offset > total', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + const result = await store.list({limit: 10, offset: 100}) + + expect(result.rows).to.deep.equal([]) + expect(result.total).to.equal(1) + }) + }) + + describe('loadPending()', () => { + it('should return empty when file does not exist', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + const rows = await store.loadPending() + + expect(rows).to.deep.equal([]) + }) + + it("should return only 'pending' rows", async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + await store.updateStatus(['r2'], 'sent') + + const rows = await store.loadPending() + + expect(rows.map((r) => r.id).sort()).to.deep.equal(['r1', 'r3']) + }) + + it('should include pending rows with attempts > 0 (in-flight retries)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1', status: 'pending'})) + await store.updateStatus(['r1'], 'failed') // attempts=1, still pending + + const rows = await store.loadPending() + + expect(rows).to.have.lengthOf(1) + expect(rows[0].id).to.equal('r1') + expect(rows[0].status).to.equal('pending') + expect(rows[0].attempts).to.equal(1) + }) + + it("should exclude rows that reached terminal 'failed'", async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await store.updateStatus(['r1'], 'failed') + } + + const rows = await store.loadPending() + + expect(rows).to.deep.equal([]) + }) + }) + + describe('concurrency (write serialization)', () => { + it('should not lose appends interleaved with updateStatus', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + // Interleave: kick off updateStatus + a fresh append without awaiting; both go into writeChain. + const updatePromise = store.updateStatus(['r1', 'r2'], 'sent') + const appendPromise = store.append(makeRecord({id: 'r-NEW'})) + + await Promise.all([updatePromise, appendPromise]) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.include('r-NEW') // append must NOT be lost by the rewrite + expect(rows).to.have.lengthOf(4) + expect(findRow(rows, 'r1').status).to.equal('sent') + expect(findRow(rows, 'r2').status).to.equal('sent') + }) + }) + + describe('cap edge cases', () => { + it('should drop oldest sent row when row cap exceeded', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 3}) + await store.append(makeRecord({id: 'r1', timestamp: 100})) + await store.append(makeRecord({id: 'r2', timestamp: 200})) + await store.append(makeRecord({id: 'r3', timestamp: 300})) + await store.updateStatus(['r1', 'r2'], 'sent') // r1 oldest sent; r2 newer sent + + await store.append(makeRecord({id: 'r4', timestamp: 400})) // triggers cap + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.not.include('r1') // oldest sent dropped + expect(ids).to.include('r2') + expect(ids).to.include('r3') + expect(ids).to.include('r4') + expect(store.droppedSentCount()).to.equal(1) + }) + + it('should drop multiple oldest sent rows in one compaction pass (single append)', async () => { + // Locks in the single-pass compaction invariant: dropping N sent + // rows in one append must drop the N oldest ones and stop dropping + // as soon as the file is under cap. + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 4}) + await store.append(makeRecord({id: 's1', timestamp: 100})) + await store.append(makeRecord({id: 's2', timestamp: 200})) + await store.append(makeRecord({id: 's3', timestamp: 300})) + await store.append(makeRecord({id: 'p4', timestamp: 400})) // pending + await store.updateStatus(['s1', 's2', 's3'], 'sent') + + // Two new pending rows pushes count to 6 — must drop two oldest sent (s1, s2). + await store.append(makeRecord({id: 'p5', timestamp: 500})) + await store.append(makeRecord({id: 'p6', timestamp: 600})) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id) + expect(ids).to.not.include('s1') + expect(ids).to.not.include('s2') + expect(ids).to.include('s3') // newest sent survives + expect(ids).to.include('p4') + expect(ids).to.include('p5') + expect(ids).to.include('p6') + expect(store.droppedSentCount()).to.equal(2) + }) + + it('should preserve pending and failed rows during compaction', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 3}) + await store.append(makeRecord({id: 'r1', timestamp: 100})) // pending + await store.append(makeRecord({id: 'r2', timestamp: 200})) // sent (will be dropped) + await store.append(makeRecord({id: 'r3', timestamp: 300})) // pending + await store.updateStatus(['r2'], 'sent') + + await store.append(makeRecord({id: 'r4', timestamp: 400})) // triggers cap + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.include('r1') // pending preserved + expect(ids).to.include('r3') // pending preserved + expect(ids).to.not.include('r2') // sent dropped + }) + + it('should throw JsonlCapFullError when cap full of pending+failed (no sent to drop)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 2}) + await store.append(makeRecord({id: 'r1', timestamp: 100})) + await store.append(makeRecord({id: 'r2', timestamp: 200})) // both pending; no sent rows + + // The new record cannot land — file already at cap and no sent rows to evict. + // The store throws JsonlCapFullError so AnalyticsClient can skip its mirror queue.push, + // preserving the JSONL=truth invariant. A silent return would let the queue diverge from disk. + let caught: unknown + try { + await store.append(makeRecord({id: 'r3', timestamp: 300})) + } catch (error) { + caught = error + } + + expect(caught, 'append must throw on cap-full silent-drop').to.be.instanceOf(JsonlCapFullError) + expect((caught as JsonlCapFullError).recordId).to.equal('r3') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.not.include('r3') + expect(ids).to.deep.equal(['r1', 'r2']) + expect(store.droppedFullCount()).to.equal(1) + }) + + it('should track droppedFullCount cumulatively across multiple cap-full throws', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 1}) + await store.append(makeRecord({id: 'r1'})) + + // Each cap-full append throws; counter increments before the throw so callers can still observe it. + for (const id of ['r2', 'r3']) { + let caught: unknown + try { + // eslint-disable-next-line no-await-in-loop + await store.append(makeRecord({id})) + } catch (error) { + caught = error + } + + expect(caught, `append('${id}') must throw on cap-full`).to.be.instanceOf(JsonlCapFullError) + } + + expect(store.droppedFullCount()).to.equal(2) + }) + + it('should throw JsonlCapFullError after partial byte-cap compaction insufficient to make room', async () => { + const baseDir = await freshTempDir() + // Tight byte cap so a single big pending row + a tiny sent row + a new big pending row + // exceeds cap even after dropping the small sent row. + const big = 'x'.repeat(400) + const tiny = 'x'.repeat(10) + const store = new JsonlAnalyticsStore({baseDir, maxBytes: 900, maxRows: 10_000}) + await store.append(makeRecord({id: 'sent-tiny', properties: {data: tiny}})) + await store.updateStatus(['sent-tiny'], 'sent') + await store.append(makeRecord({id: 'p1', properties: {data: big}})) // ~400-byte payload, fits + + // p2 is ~400 bytes; together with p1 (~400) the two pending rows alone are ~800 bytes. + // Dropping sent-tiny saves ~10 bytes; combined with p1+p2 the file is still over the 900-byte cap. + let caught: unknown + try { + await store.append(makeRecord({id: 'p2', properties: {data: big}})) + } catch (error) { + caught = error + } + + expect(caught, 'append must throw when even full sent compaction leaves file over byte cap').to.be.instanceOf( + JsonlCapFullError, + ) + expect((caught as JsonlCapFullError).recordId).to.equal('p2') + + // The store still persisted the sent-row drop (partial compaction) before throwing, + // so observers see the most-up-to-date state on disk. + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.deep.equal(['p1']) // sent-tiny dropped, p2 not added + expect(store.droppedFullCount()).to.equal(1) + expect(store.droppedSentCount()).to.equal(1) + }) + + it('should silently skip malformed JSON lines on read', async () => { + const baseDir = await freshTempDir() + const filePath = join(baseDir, 'analytics-queue.jsonl') + const good = makeRecord({id: 'good'}) + // Two bad lines (non-JSON garbage) sandwiching a good one. + await writeFile( + filePath, + ['this is not json', JSON.stringify(good), 'partial-write-{'].join('\n') + '\n', + 'utf8', + ) + const store = new JsonlAnalyticsStore({baseDir}) + + const rows = await store.loadPending() + + expect(rows).to.have.lengthOf(1) + expect(rows[0].id).to.equal('good') + }) + + it('should silently skip schema-invalid JSON objects on read', async () => { + const baseDir = await freshTempDir() + const filePath = join(baseDir, 'analytics-queue.jsonl') + const good = makeRecord({id: 'good'}) + // First line parses as JSON but fails Zod (missing required fields). + await writeFile( + filePath, + [JSON.stringify({notAValidRecord: true}), JSON.stringify(good)].join('\n') + '\n', + 'utf8', + ) + const store = new JsonlAnalyticsStore({baseDir}) + + const rows = await store.loadPending() + + expect(rows).to.have.lengthOf(1) + expect(rows[0].id).to.equal('good') + }) + + it('should respect byte cap as well as row cap', async () => { + const baseDir = await freshTempDir() + const big = 'x'.repeat(200) + // Compute the serialized row size dynamically so the cap holds 2 rows comfortably + // but 3 rows tip over, regardless of identity/uuid serialization length drift. + const sampleSize = JSON.stringify(makeRecord({properties: {data: big}})).length + 1 + const maxBytes = sampleSize * 2 + 50 + const store = new JsonlAnalyticsStore({baseDir, maxBytes, maxRows: 10_000}) + await store.append(makeRecord({id: 'r1', properties: {data: big}})) + await store.append(makeRecord({id: 'r2', properties: {data: big}})) + await store.updateStatus(['r1'], 'sent') + + await store.append(makeRecord({id: 'r3', properties: {data: big}})) // triggers byte-cap + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.not.include('r1') // dropped (sent + oldest) + expect(store.droppedSentCount()).to.be.greaterThanOrEqual(1) + }) + }) + + describe('clear() — M4.1 truncate on auth transition', () => { + it('should remove every row regardless of status', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'pending'})) + await store.append(makeRecord({id: 'sent'})) + await store.append(makeRecord({id: 'failed'})) + await store.updateStatus(['sent'], 'sent') + // Push the third row to terminal 'failed' by hammering past the cap. + // Promise.all is safe here: writeChain serializes them in fire-order. + await Promise.all( + Array.from({length: MAX_ATTEMPTS}, () => store.updateStatus(['failed'], 'failed')), + ) + + await store.clear() + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows).to.have.lengthOf(0) + }) + + it('should leave the file empty (zero bytes), not absent', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + await store.clear() + + const stats = await stat(join(baseDir, 'analytics-queue.jsonl')) + expect(stats.size).to.equal(0) + }) + + it('should be a no-op when the file does not exist yet', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + // Must not throw even when no append has happened. + await store.clear() + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows).to.have.lengthOf(0) + }) + + it('should NOT reset cumulative lifetime counters (droppedFullCount, droppedSentCount)', async () => { + const baseDir = await freshTempDir() + // Force a `droppedSent` via byte-cap (mirror earlier cap test). + const big = 'x'.repeat(200) + const sampleSize = JSON.stringify(makeRecord({properties: {data: big}})).length + 1 + const store = new JsonlAnalyticsStore({baseDir, maxBytes: sampleSize * 2 + 50, maxRows: 10_000}) + await store.append(makeRecord({id: 'r1', properties: {data: big}})) + await store.append(makeRecord({id: 'r2', properties: {data: big}})) + await store.updateStatus(['r1'], 'sent') + await store.append(makeRecord({id: 'r3', properties: {data: big}})) // drops r1 + const droppedBefore = store.droppedSentCount() + expect(droppedBefore).to.be.greaterThanOrEqual(1) + + await store.clear() + + expect(store.droppedSentCount()).to.equal(droppedBefore) + }) + + it('should serialize through the write chain (concurrent append + clear preserves the clear)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + // Seed and immediately race a clear + a new append. + await store.append(makeRecord({id: 'old-1'})) + await store.append(makeRecord({id: 'old-2'})) + + // Fire concurrently — order of enqueue determines the on-disk state. + const clearPromise = store.clear() + const appendPromise = store.append(makeRecord({id: 'new-1'})) + await Promise.all([clearPromise, appendPromise]) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id) + expect(ids).to.not.include('old-1') + expect(ids).to.not.include('old-2') + // The append enqueued AFTER clear must survive (queue order serializes writes). + expect(ids).to.include('new-1') + }) + }) +}) diff --git a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts new file mode 100644 index 000000000..1844314d8 --- /dev/null +++ b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {NoOpAnalyticsClient} from '../../../../../src/server/infra/analytics/no-op-analytics-client.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +describe('NoOpAnalyticsClient', () => { + describe('track()', () => { + it('should return void without throwing across every catalog event', () => { + const client = new NoOpAnalyticsClient() + + // DAEMON_START has no required properties. + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + + // CURATE_OPERATION_APPLIED with a minimal valid payload. + expect(() => + client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { + keywords: [], + knowledge_path: 'kg/x.md', + needs_review: false, + operation_type: 'ADD', + relative_path: 'tmp/x.md', + tags: [], + task_id: 't-1', + }), + ).to.not.throw() + + // QUERY_COMPLETED with a minimal valid payload. + expect(() => + client.track(AnalyticsEventNames.QUERY_COMPLETED, { + cache_hit: false, + duration_ms: 0, + matched_doc_count: 0, + outcome: 'completed', + read_doc_count: 0, + read_tool_call_count: 0, + search_call_count: 0, + task_id: 't-1', + task_type: 'query', + }), + ).to.not.throw() + }) + + it('should remain a no-op under burst load', () => { + const client = new NoOpAnalyticsClient() + + for (let i = 0; i < 1000; i++) { + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + } + }) + }) + + describe('flush()', () => { + it('should resolve to an empty batch with schema_version: 2', async () => { + const client = new NoOpAnalyticsClient() + + const batch = await client.flush() + + expect(batch.schema_version).to.equal(2) + expect(batch.events).to.deep.equal([]) + }) + + it('should still return an empty batch after many track() calls (track is truly a no-op)', async () => { + const client = new NoOpAnalyticsClient() + + for (let i = 0; i < 100; i++) { + client.track(AnalyticsEventNames.DAEMON_START) + } + + const batch = await client.flush() + + expect(batch.events).to.deep.equal([]) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts b/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts new file mode 100644 index 000000000..323ed2a31 --- /dev/null +++ b/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' + +import type {SendResult} from '../../../../../src/server/core/interfaces/analytics/i-analytics-sender.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {NoOpAnalyticsSender} from '../../../../../src/server/infra/analytics/no-op-analytics-sender.js' + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: randomUUID(), + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 0, + ...overrides, + } +} + +describe('NoOpAnalyticsSender', () => { + describe('send()', () => { + it('should return both arrays empty for empty input', async () => { + const sender = new NoOpAnalyticsSender() + + const result = await sender.send([]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('should return both arrays empty for a single-record input', async () => { + const sender = new NoOpAnalyticsSender() + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('should return both arrays empty for a many-record input', async () => { + const sender = new NoOpAnalyticsSender() + const records = Array.from({length: 50}, (_, i) => makeRecord({id: `r${i}`})) + + const result = await sender.send(records) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('should leave JSONL state untouched when result is piped through a fake updateStatus recorder', async () => { + // Locked decision: NoOpAnalyticsSender must be semantically inert under M10.2's mirror wiring. + // Piping its result into updateStatus(succeeded, 'sent') + updateStatus(failed, 'failed') + // must produce ZERO status mutations, so JSONL rows stay at status='pending' until M4.2 + // wires the real HTTP sender. This guards the data-loss hazard called out in M10/README.md. + const sender = new NoOpAnalyticsSender() + const records = [makeRecord({id: 'r1'}), makeRecord({id: 'r2'}), makeRecord({id: 'r3'})] + + const recorder: Array<{ids: readonly string[]; status: 'failed' | 'sent'}> = [] + const fakeUpdateStatus = async (ids: readonly string[], status: 'failed' | 'sent'): Promise => { + if (ids.length === 0) return + recorder.push({ids, status}) + } + + const result: SendResult = await sender.send(records) + await fakeUpdateStatus(result.succeeded, 'sent') + await fakeUpdateStatus(result.failed, 'failed') + + expect(recorder, 'NoOp must not produce any status mutation').to.deep.equal([]) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/super-properties-resolver.test.ts b/test/unit/server/infra/analytics/super-properties-resolver.test.ts new file mode 100644 index 000000000..4fef8b664 --- /dev/null +++ b/test/unit/server/infra/analytics/super-properties-resolver.test.ts @@ -0,0 +1,185 @@ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {SuperPropertiesResolver} from '../../../../../src/server/infra/analytics/super-properties-resolver.js' +import {runWithClientKind} from '../../../../../src/server/infra/transport/client-kind-context.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeStubStore(deviceId = validDeviceId): IGlobalConfigStore { + const config = GlobalConfig.fromJson({ + analytics: false, + deviceId, + version: '0.0.1', + }) + if (!config) { + throw new Error('test fixture: GlobalConfig.fromJson must succeed') + } + + return { + read: stub().resolves(config), + write: stub().resolves(), + } +} + +describe('SuperPropertiesResolver', () => { + let savedBrvEnv: string | undefined + + beforeEach(() => { + savedBrvEnv = process.env.BRV_ENV + }) + + afterEach(() => { + if (savedBrvEnv === undefined) { + delete process.env.BRV_ENV + } else { + process.env.BRV_ENV = savedBrvEnv + } + + restore() + }) + + describe('resolved shape (ticket scenario 1)', () => { + it('should contain all five keys', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props).to.have.all.keys('device_id', 'cli_version', 'os', 'node_version', 'environment') + }) + }) + + describe('client_kind', () => { + it('omits client_kind when no clientKindContext scope is active', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props).to.not.have.property('client_kind') + }) + + it('stamps client_kind=cli when wrapped in runWithClientKind("cli")', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await runWithClientKind('cli', () => resolver.resolve()) + + expect(props.client_kind).to.equal('cli') + }) + + it('stamps client_kind=webui when wrapped in runWithClientKind("webui")', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await runWithClientKind('webui', () => resolver.resolve()) + + expect(props.client_kind).to.equal('webui') + }) + + it('keeps the other super-properties stable when client_kind is added', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await runWithClientKind('tui', () => resolver.resolve()) + + expect(props.cli_version).to.equal('1.2.3') + expect(props.device_id).to.equal(validDeviceId) + }) + }) + + describe('device_id (ticket scenario 2)', () => { + it('should match what IGlobalConfigStore returned', async () => { + const customId = '11111111-1111-1111-1111-111111111111' + const resolver = new SuperPropertiesResolver(makeStubStore(customId), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.device_id).to.equal(customId) + }) + + it('should re-read device_id on every resolve() call', async () => { + const store = makeStubStore() + const resolver = new SuperPropertiesResolver(store, () => '1.2.3') + + await resolver.resolve() + await resolver.resolve() + await resolver.resolve() + + const readStub = store.read as ReturnType + expect(readStub.callCount).to.equal(3) + }) + }) + + describe('cli_version (ticket scenario 3)', () => { + it('should match what versionReader returned', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '9.9.9') + + const props = await resolver.resolve() + + expect(props.cli_version).to.equal('9.9.9') + }) + }) + + describe('os (ticket scenario 4)', () => { + it('should match process.platform', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.os).to.equal(process.platform) + }) + }) + + describe('node_version (ticket scenario 5)', () => { + it('should match process.version', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.node_version).to.equal(process.version) + }) + }) + + describe('environment (ticket scenario 6)', () => { + it("should be 'development' when BRV_ENV=development", async () => { + process.env.BRV_ENV = 'development' + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.environment).to.equal('development') + }) + + it("should be 'production' when BRV_ENV=production", async () => { + process.env.BRV_ENV = 'production' + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.environment).to.equal('production') + }) + + it("should default to 'production' when BRV_ENV is unset", async () => { + delete process.env.BRV_ENV + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.environment).to.equal('production') + }) + }) + + describe('static-field caching (ticket scenario 7)', () => { + it('should call versionReader only once across many resolve() calls', async () => { + const versionReader = stub().returns('1.2.3') + const resolver = new SuperPropertiesResolver(makeStubStore(), versionReader) + + await resolver.resolve() + await resolver.resolve() + await resolver.resolve() + + expect(versionReader.callCount).to.equal(1) + }) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook-m14.test.ts b/test/unit/server/infra/process/analytics-hook-m14.test.ts new file mode 100644 index 000000000..4d83ef7e7 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook-m14.test.ts @@ -0,0 +1,368 @@ +import {expect} from 'chai' +import sinon from 'sinon' + +import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +const FIXED_NOW = 1_700_000_000_000 + +const buildClient = (): {client: IAnalyticsClient; trackStub: sinon.SinonStub} => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildTask = (type: string, overrides: Partial = {}): TaskInfo => + ({ + clientId: 'c1', + completedAt: FIXED_NOW + 1234, + content: 'whatever', + createdAt: FIXED_NOW, + folderPath: undefined, + projectPath: '/project', + taskId: `task-${type}-1`, + toolCalls: [], + type, + ...overrides, + }) as TaskInfo + +const buildCurateOpToolResult = (): LlmToolResultEvent => + ({ + callId: 'call-1', + result: JSON.stringify({ + applied: [ + {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, + ], + }), + sessionId: 's1', + taskId: 'task-curate-1', + timestamp: FIXED_NOW, + toolName: 'curate' as const, + }) as unknown as LlmToolResultEvent + +const eventSequence = (trackStub: sinon.SinonStub): string[] => + trackStub.getCalls().map((c) => c.args[0] as string) + +describe('AnalyticsHook M14.3 generic task_* emit simulation', () => { + let hook: AnalyticsHook + let trackStub: sinon.SinonStub + + beforeEach(() => { + const bundle = buildClient() + trackStub = bundle.trackStub + hook = new AnalyticsHook() + hook.setAnalyticsClient(bundle.client) + }) + + describe('curate task: full success lifecycle (curate-tool-mode rename simulated)', () => { + it('emits task_created on entry, then per-op + curate_run_completed + task_completed on terminal', async () => { + // Daemon dispatches the pre-ENG-2925 name 'curate-html-direct'; + // analytics is expected to alias-translate to 'curate-tool-mode'. + const task = buildTask('curate-html-direct', {taskId: 'task-curate-1'}) + + await hook.onTaskCreate(task) + await hook.onToolResult(task.taskId, buildCurateOpToolResult()) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.CURATE_OPERATION_APPLIED, + AnalyticsEventNames.CURATE_RUN_COMPLETED, + AnalyticsEventNames.TASK_COMPLETED, + ]) + }) + + it('aliases curate-html-direct → curate-tool-mode on the wire (task_type field)', async () => { + const task = buildTask('curate-html-direct', {taskId: 'task-curate-1'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const completed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_COMPLETED) + expect((created?.args[1] as Record).task_type).to.equal('curate-tool-mode') + expect((completed?.args[1] as Record).task_type).to.equal('curate-tool-mode') + }) + + it('emits task_created has_files=true / has_folder=true when set on TaskInfo', async () => { + const task = buildTask('curate-folder', { + files: ['/a.ts', '/b.ts'], + folderPath: '/some/folder', + taskId: 'task-curate-2', + }) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const props = created?.args[1] as Record + expect(props.has_files).to.equal(true) + expect(props.has_folder).to.equal(true) + expect(props.task_type).to.equal('curate-folder') + }) + + it('emits task_created has_files=false / has_folder=false when both are unset', async () => { + const task = buildTask('curate', {files: undefined, folderPath: undefined, taskId: 'task-curate-3'}) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const props = created?.args[1] as Record + expect(props.has_files).to.equal(false) + expect(props.has_folder).to.equal(false) + }) + }) + + describe('query task: terminal emits include task_completed last', () => { + it('emits task_created → query_completed → task_completed in order', async () => { + const task = buildTask('query-tool-mode', {taskId: 'task-query-1'}) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.QUERY_COMPLETED, + AnalyticsEventNames.TASK_COMPLETED, + ]) + }) + + it('emits the same query-tool-mode task_type across all three events', async () => { + const task = buildTask('query-tool-mode', {taskId: 'task-query-1'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + for (const call of trackStub.getCalls()) { + const props = call.args[1] as Record + expect(props.task_type, `${call.args[0] as string} carried wrong task_type`).to.equal('query-tool-mode') + } + }) + }) + + describe('dream-scan / dream-finalize / search: only task_* emits fire (no M12 per-flavor)', () => { + for (const taskType of ['dream-scan', 'dream-finalize', 'search'] as const) { + it(`${taskType}: task_created → task_completed (no curate/query M12 emit)`, async () => { + const task = buildTask(taskType, {taskId: `task-${taskType}-1`}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.TASK_COMPLETED, + ]) + }) + + it(`${taskType}: onTaskError emits task_created then task_failed (no curate/query M12 emit)`, async () => { + const task = buildTask(taskType, {taskId: `task-${taskType}-2`}) + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'something blew up', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.TASK_FAILED, + ]) + }) + } + }) + + describe('failure + cancellation both surface as task_failed', () => { + it('curate onTaskError emits curate_run_completed(outcome=error) then task_failed', async () => { + const task = buildTask('curate', {taskId: 'task-curate-err'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'kaboom', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.CURATE_RUN_COMPLETED, + AnalyticsEventNames.TASK_FAILED, + ]) + }) + + it('curate onTaskCancelled also emits task_failed (no distinct cancellation event)', async () => { + const task = buildTask('curate', {taskId: 'task-curate-cancel'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskCancelled(task.taskId, task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.CURATE_RUN_COMPLETED, + AnalyticsEventNames.TASK_FAILED, + ]) + }) + + it('task_failed payload carries duration_ms + task_id + canonical task_type + failure_kind', async () => { + const task = buildTask('query', {taskId: 'task-query-err'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'kaboom', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + const props = failed?.args[1] as Record + expect(props.task_id).to.equal('task-query-err') + expect(props.task_type).to.equal('query') + expect(props.duration_ms).to.equal(1234) + // 'kaboom' classifies to 'unknown' — no recognised sentinel substring + expect(props.failure_kind).to.equal('unknown') + }) + + it('failure_kind is "cancelled" on onTaskCancelled regardless of state', async () => { + const task = buildTask('curate', {taskId: 'task-cancel-fk'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskCancelled(task.taskId, task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('cancelled') + }) + + it('failure_kind is "timeout" when the error message names a timeout', async () => { + const task = buildTask('search', {taskId: 'task-timeout'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'request timed out after 30s', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('timeout') + }) + + it('failure_kind is "agent_error" when the error message points at the agent layer', async () => { + const task = buildTask('search', {taskId: 'task-agent-err'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'provider rejected the LLM call', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('agent_error') + }) + + it('classifier uses word-boundary matching: "tooltip" / "engagement" do NOT bucket into agent_error (PR #722)', async () => { + const task = buildTask('search', {taskId: 'task-tooltip'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'could not render tooltip in engagement panel', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('unknown') + }) + + it('classifier precedence pinned: timeout wins over agent_error when both substrings present (PR #722)', async () => { + const task = buildTask('search', {taskId: 'task-both'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'llm provider timeout after 30s', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('timeout') + }) + }) + + describe('toAnalyticsTaskType drift guard (PR #722)', () => { + it('emits the "unknown" sentinel for an un-enumerated daemon task type instead of silently failing the wire enum', async () => { + const task = buildTask('not-a-real-daemon-type', {taskId: 'task-drift'}) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + expect((created?.args[1] as Record).task_type).to.equal('unknown') + }) + }) + + describe('toRelativePath outside-project guard (PR #722)', () => { + it('replaces escaping ../ paths with the bare sentinel (no basename leak)', async () => { + const task = buildTask('curate', {projectPath: '/Users/dev/proj', taskId: 'task-outside'}) + await hook.onTaskCreate(task) + const result: LlmToolResultEvent = { + callId: 'c1', + result: JSON.stringify({ + applied: [{filePath: '/tmp/x.md', needsReview: false, path: 'x', status: 'success', type: 'ADD'}], + }), + sessionId: 's1', + taskId: 'task-outside', + timestamp: FIXED_NOW, + toolName: 'curate' as const, + } as unknown as LlmToolResultEvent + await hook.onToolResult('task-outside', result) + + const op = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect((op?.args[1] as Record).relative_path).to.equal('') + }) + + it('replaces a raw absolute path with the bare sentinel when projectPath is undefined', async () => { + const task = buildTask('curate', {projectPath: undefined, taskId: 'task-no-proj'}) + await hook.onTaskCreate(task) + const result: LlmToolResultEvent = { + callId: 'c1', + result: JSON.stringify({ + applied: [{filePath: '/home/u/secret.md', needsReview: false, path: 'x', status: 'success', type: 'ADD'}], + }), + sessionId: 's1', + taskId: 'task-no-proj', + timestamp: FIXED_NOW, + toolName: 'curate' as const, + } as unknown as LlmToolResultEvent + await hook.onToolResult('task-no-proj', result) + + const op = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED) + // The leaf token (`secret.md`) must NOT survive: a file outside the + // project root carries the highest PII risk and the least analytical + // value, so it collapses to the bare sentinel. + expect((op?.args[1] as Record).relative_path).to.equal('') + }) + }) + + describe('project_path_hash (M17 follow-up): join-key parity with other handler-emitted events', () => { + it('stamps the sha256(projectPath) on every emit when task.projectPath is set', async () => { + const task = buildTask('curate', {projectPath: '/Users/dev/example-project', taskId: 'task-pph-1'}) + await hook.onTaskCreate(task) + await hook.onToolResult('task-pph-1', buildCurateOpToolResult()) + await hook.onTaskCompleted('task-pph-1', '', task) + + const events = trackStub.getCalls().map((c) => ({ + name: c.args[0] as string, + props: c.args[1] as Record, + })) + + // Every payload carries project_path_hash matching the sha256 hex regex. + for (const {name, props} of events) { + expect(props.project_path_hash, `${name} should carry project_path_hash`).to.be.a('string').and.match(/^[0-9a-f]{64}$/) + } + + // All payloads share the same hash (same projectPath). Positive byte-for-byte + // verification against `hashProjectPath()` lives in the next `it`. + const hashes = new Set(events.map((e) => e.props.project_path_hash)) + expect(hashes.size, 'all emits for one task share the same project_path_hash').to.equal(1) + }) + + it('omits the field when task.projectPath is undefined', async () => { + const task = buildTask('search', {projectPath: undefined, taskId: 'task-pph-noproj'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted('task-pph-noproj', '', task) + + const events = trackStub.getCalls().map((c) => c.args[1] as Record) + for (const props of events) { + expect(props).to.not.have.property('project_path_hash') + } + }) + + it('matches hashProjectPath(projectPath) — verifiable from the public utility', async () => { + const {hashProjectPath} = await import('../../../../../src/server/utils/hash-path.js') + const projectPath = '/Users/dev/some/other/proj' + const expected = hashProjectPath(projectPath) + + const task = buildTask('curate', {projectPath, taskId: 'task-pph-match'}) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const props = created?.args[1] as Record + expect(props.project_path_hash).to.equal(expected) + }) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts b/test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts new file mode 100644 index 000000000..e87948ac8 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts @@ -0,0 +1,175 @@ +import {expect} from 'chai' +import sinon from 'sinon' + +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {TaskTypes} from '../../../../../src/shared/analytics/task-types.js' + +const FIXED_NOW = 1_700_000_000_000 + +function buildAnalyticsClient(): {client: IAnalyticsClient; trackStub: sinon.SinonStub} { + const trackStub = sinon.stub() + return { + client: { + abort() { + /* unused here */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + }, + trackStub, + } +} + +const buildMcpQueryTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'sock-1', + clientName: 'Cursor', + clientType: 'mcp', + completedAt: FIXED_NOW + 2500, + content: 'query', + createdAt: FIXED_NOW, + projectPath: '/proj', + taskId: 'task-1', + type: TaskTypes.QUERY_TOOL_MODE, + ...overrides, + }) as TaskInfo + +const buildMcpCurateTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'sock-1', + clientName: 'Claude Code', + clientType: 'mcp', + completedAt: FIXED_NOW + 8000, + content: 'curate', + createdAt: FIXED_NOW, + projectPath: '/proj', + taskId: 'task-c', + type: TaskTypes.CURATE_TOOL_MODE, + ...overrides, + }) as TaskInfo + +const mcpToolCalledCalls = (trackStub: sinon.SinonStub): sinon.SinonSpyCall[] => + trackStub.getCalls().filter((c) => c.args[0] === AnalyticsEventNames.MCP_TOOL_CALLED) + +describe('AnalyticsHook MCP_TOOL_CALLED emit (M15.8)', () => { + let trackStub: sinon.SinonStub + let hook: AnalyticsHook + + beforeEach(() => { + const bundle = buildAnalyticsClient() + trackStub = bundle.trackStub + hook = new AnalyticsHook() + hook.setAnalyticsClient(bundle.client) + }) + + it('on success: emits mcp_tool_called with tool_name=brv-query for QUERY_TOOL_MODE', async () => { + const task = buildMcpQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + client_name: string + duration_ms: number + success: boolean + tool_name: string + } + expect(props.tool_name).to.equal('brv-query') + expect(props.client_name).to.equal('Cursor') + expect(props.success).to.equal(true) + expect(props.duration_ms).to.equal(2500) + }) + + it('on success: emits mcp_tool_called with tool_name=brv-curate for CURATE_TOOL_MODE', async () => { + const task = buildMcpCurateTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {client_name: string; duration_ms: number; success: boolean; tool_name: string} + expect(props.tool_name).to.equal('brv-curate') + expect(props.client_name).to.equal('Claude Code') + expect(props.success).to.equal(true) + expect(props.duration_ms).to.equal(8000) + }) + + it('on error: emits mcp_tool_called with success=false (still surfaces the call)', async () => { + const task = buildMcpQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'boom', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {success: boolean; tool_name: string} + expect(props.success).to.equal(false) + expect(props.tool_name).to.equal('brv-query') + }) + + it('on cancellation: emits mcp_tool_called with success=false (user-cancel is a not-completed tool call)', async () => { + const task = buildMcpQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCancelled(task.taskId, task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {client_name: string; success: boolean; tool_name: string} + expect(props.success).to.equal(false) + expect(props.tool_name).to.equal('brv-query') + expect(props.client_name).to.equal('Cursor') + }) + + it('does NOT emit when clientType is not "mcp"', async () => { + const task = buildMcpQueryTask({clientType: 'cli'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + expect(mcpToolCalledCalls(trackStub).length).to.equal(0) + }) + + it('does NOT emit when clientType is undefined', async () => { + const task = buildMcpQueryTask({clientType: undefined}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + expect(mcpToolCalledCalls(trackStub).length).to.equal(0) + }) + + it('does NOT emit for non-tool-mode task types (e.g. CURATE, QUERY, DREAM_SCAN, SEARCH)', async () => { + const types = [TaskTypes.CURATE, TaskTypes.QUERY, TaskTypes.DREAM_SCAN, TaskTypes.SEARCH] + for (const t of types) { + const task = buildMcpQueryTask({taskId: `t-${t}`, type: t}) + // eslint-disable-next-line no-await-in-loop -- sequential setup for sinon stub assertions + await hook.onTaskCreate(task) + // eslint-disable-next-line no-await-in-loop + await hook.onTaskCompleted(task.taskId, '', task) + } + + expect(mcpToolCalledCalls(trackStub).length).to.equal(0) + }) + + it('falls back to "unknown" for client_name when the snapshot is missing', async () => { + const task = buildMcpQueryTask({clientName: undefined}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + expect((calls[0].args[1] as {client_name: string}).client_name).to.equal('unknown') + }) + + it('duration_ms uses durationMs helper (clamps at 0 on clock skew)', async () => { + const task = buildMcpQueryTask({completedAt: FIXED_NOW - 1000}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect((calls[0].args[1] as {duration_ms: number}).duration_ms).to.equal(0) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts b/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts new file mode 100644 index 000000000..c3d0ecf88 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts @@ -0,0 +1,330 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import sinon from 'sinon' + +import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {QueryResultMetadata} from '../../../../../src/server/infra/process/query-log-handler.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' + +const NOW = 1_700_000_000_000 + +const buildClient = (): {client: IAnalyticsClient; trackStub: sinon.SinonStub} => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildToolModeCurateTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'agent-1', + completedAt: NOW + 4321, + content: 'JSON envelope describing the html the calling agent already wrote', + createdAt: NOW, + // Daemon today still dispatches the pre-ENG-2925 name; analytics + // aliases it to 'curate-tool-mode' on the wire via toAnalyticsTaskType. + projectPath: '/Users/dev/example-project', + taskId: 'task-curate-tm-1', + type: 'curate-html-direct', + ...overrides, + }) as TaskInfo + +const buildToolModeQueryTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'agent-1', + completedAt: NOW + 987, + content: 'how does the auth middleware work', + createdAt: NOW, + projectPath: '/Users/dev/example-project', + taskId: 'task-query-tm-1', + toolCalls: [], + type: 'query-tool-mode', + ...overrides, + }) as TaskInfo + +async function fakeReadFileForInspection(filePath: string): Promise { + if (filePath === '/Users/dev/example-project/.brv/notes/auth.md') { + return '---\nkeywords: ["jwt", "session"]\nrelated: ["auth/middleware", "users"]\ntags: ["security"]\n---\nbody\n' + } + + if (filePath === '/Users/dev/example-project/.brv/context-tree/analytics/m17.html') { + // M17 follow-up: curate-tool-mode writes HTML topic files whose + // tags/keywords/related live as comma-separated attrs on ``. + return 'noop' + } + + return '---\n---\nempty\n' +} + +/** + * PR #722 review: gated behind `DUMP_ANALYTICS=1` so `npm test` stays quiet + * by default. The shape assertions in each `it()` still execute; the dump + * is an opt-in diagnostic for inspecting payloads (`DUMP_ANALYTICS=1 npx + * mocha test/unit/.../analytics-hook-toolmode-inspection.test.ts`). + */ +const DUMP_ENABLED = process.env.DUMP_ANALYTICS === '1' + +const dumpEvents = (label: string, trackStub: sinon.SinonStub): void => { + if (!DUMP_ENABLED) return + console.log(`\n┌─ ${label} ${'─'.repeat(Math.max(0, 70 - label.length))}`) + for (const [i, call] of trackStub.getCalls().entries()) { + const eventName = call.args[0] as string + const props = call.args[1] as Record + console.log(`│ [${i}] ${eventName}`) + console.log(`│ ${JSON.stringify(props, null, 2).replaceAll('\n', '\n│ ')}`) + } + + console.log(`└${'─'.repeat(72)}\n`) +} + +describe('analytics-hook tool-mode event inspection (M14)', () => { + it('curate-tool-mode: prints every event + payload the daemon emits to analytics', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeCurateTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('curate-tool-mode — success path', trackStub) + + // Sanity: every event carries the canonical post-rename task_type + for (const call of trackStub.getCalls()) { + const props = call.args[1] as Record + expect(props.task_type, `${call.args[0] as string} task_type`).to.equal('curate-tool-mode') + } + + // Counters all-zero today because onToolResult never fires for + // tool-mode (no LLM tool calls) — that's the FU-1 follow-up. + // + // FU-1 forward-compat note (PR #722 review): once FU-1 lands and the + // daemon synthesises a curate op from `task.result`, these asserts + // will flip from `=== 0` to non-zero. That is a FEATURE, not a + // regression — update the expectations in the FU-1 PR. + const runCompleted = trackStub.getCalls().find((c) => c.args[0] === 'curate_run_completed') + const counters = runCompleted?.args[1] as Record + expect(counters.operations_added).to.equal(0) + expect(counters.operations_updated).to.equal(0) + expect(counters.operations_deleted).to.equal(0) + expect(counters.operations_merged).to.equal(0) + expect(counters.operations_failed).to.equal(0) + expect(counters.pending_review_count).to.equal(0) + }) + + it('curate-tool-mode: error path — prints curate_run_completed(outcome=error) + task_failed', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeCurateTask({taskId: 'task-curate-tm-err'}) + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'writer rejected: path-exists', task) + + dumpEvents('curate-tool-mode — error path', trackStub) + }) + + it('curate-tool-mode: with a successful tool-result op (FU-1 forward-look — what counters WOULD look like)', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeCurateTask({taskId: 'task-curate-tm-fu1'}) + await hook.onTaskCreate(task) + + // Simulate a curate-op as if FU-1 had synthesised one from task.result + // (today's tool-mode path doesn't fire onToolResult — FU-1 fixes that). + const simulatedOp: LlmToolResultEvent = { + callId: 'sim-1', + result: JSON.stringify({ + applied: [ + { + filePath: '/Users/dev/example-project/.brv/notes/auth.md', + needsReview: false, + path: 'auth', + status: 'success', + type: 'ADD', + }, + ], + }), + sessionId: 'sim-session', + taskId: task.taskId, + timestamp: NOW, + toolName: 'curate' as const, + } as unknown as LlmToolResultEvent + await hook.onToolResult(task.taskId, simulatedOp) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('curate-tool-mode — FU-1 forward-look (single synthetic op)', trackStub) + }) + + it('query-tool-mode: prints every event + payload the daemon emits to analytics', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('query-tool-mode — success path (no setQueryResult)', trackStub) + + for (const call of trackStub.getCalls()) { + const props = call.args[1] as Record + expect(props.task_type, `${call.args[0] as string} task_type`).to.equal('query-tool-mode') + } + + // No setQueryResult call → matched_doc_count + read_doc_count both 0, + // tier omitted, read_paths_with_metadata omitted. That's the + // empty-metadata state FU-1's query half closes. + const queryCompleted = trackStub.getCalls().find((c) => c.args[0] === 'query_completed') + const props = queryCompleted?.args[1] as Record + expect(props.matched_doc_count).to.equal(0) + expect(props.read_doc_count).to.equal(0) + expect(props.cache_hit).to.equal(false) + expect(props.tier).to.equal(undefined) + expect(props.read_paths_with_metadata).to.equal(undefined) + }) + + it('query-tool-mode: with setQueryResult (forward-look from FU-1) — populated metadata', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({taskId: 'task-query-tm-fu1'}) + await hook.onTaskCreate(task) + + const metadata: QueryResultMetadata = { + matchedDocs: [], + searchMetadata: {resultCount: 4, topScore: 0.82, totalFound: 4}, + tier: 2, + timing: {durationMs: 987}, + } as QueryResultMetadata + hook.setQueryResult(task.taskId, metadata) + + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('query-tool-mode — FU-1 forward-look (setQueryResult populated)', trackStub) + }) + + it('query (legacy): read_paths_with_metadata carries structured related_paths + relative_path + keywords/tags arrays', async () => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + const hook = new AnalyticsHook({readFile: fakeReadFileForInspection}) + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({ + taskId: 'task-query-paths-1', + toolCalls: [ + { + args: {filePath: '/Users/dev/example-project/.brv/notes/auth.md'}, + sessionId: 's1', + status: 'completed', + timestamp: NOW, + toolName: 'read_file', + }, + ], + type: 'query', + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('query (legacy) — read_paths_with_metadata + related_paths structure', trackStub) + + const queryCompleted = trackStub.getCalls().find((c) => c.args[0] === 'query_completed') + const props = queryCompleted?.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(1) + + const entry = paths[0] + expect(entry.relative_path).to.equal('.brv/notes/auth.md') + expect(entry.keywords).to.deep.equal(['jwt', 'session']) + expect(entry.tags).to.deep.equal(['security']) + expect(entry.related_paths).to.deep.equal([ + {keywords: [], relative_path: 'auth/middleware', tags: []}, + {keywords: [], relative_path: 'users', tags: []}, + ]) + }) + + it('query-tool-mode: error path', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({taskId: 'task-query-tm-err'}) + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'connector unreachable', task) + + dumpEvents('query-tool-mode — error path', trackStub) + }) + + it('HTML topic: read_paths_with_metadata extracts keywords/tags/related from `` attrs (M17 follow-up)', async () => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + const hook = new AnalyticsHook({readFile: fakeReadFileForInspection}) + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({ + taskId: 'task-query-html-1', + toolCalls: [ + { + args: {filePath: '/Users/dev/example-project/.brv/context-tree/analytics/m17.html'}, + sessionId: 's1', + status: 'completed', + timestamp: NOW, + toolName: 'read_file', + }, + ], + type: 'query', + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const queryCompleted = trackStub.getCalls().find((c) => c.args[0] === 'query_completed') + const props = queryCompleted?.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(1) + const entry = paths[0] + expect(entry.relative_path).to.equal('.brv/context-tree/analytics/m17.html') + // M17: comma-separated `tags`/`keywords`/`related` HTML attrs become arrays. + expect(entry.keywords).to.deep.equal(['synthetic', 'broadcast-skip']) + expect(entry.tags).to.deep.equal(['analytics', 'm17', 'tool-mode']) + // `related` lifts to the structured `related_paths[]` shape: each entry's + // own keywords/tags arrays default to empty (only top-level reads enrich). + expect(entry.related_paths).to.deep.equal([ + // PR #728 review: `@` prefix is canonicalized off so HTML and YAML + // produce the same wire shape for `related_paths[].relative_path`. + {keywords: [], relative_path: 'analytics/related.html', tags: []}, + {keywords: [], relative_path: 'analytics/another.html', tags: []}, + ]) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts new file mode 100644 index 000000000..272208688 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -0,0 +1,972 @@ +import {expect} from 'chai' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import sinon from 'sinon' + +import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {QueryResultMetadata} from '../../../../../src/server/infra/process/query-log-handler.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +const writeMarkdown = (filePath: string, frontmatter: Record, body = 'body'): void => { + const yaml = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join('\n') + writeFileSync(filePath, `---\n${yaml}\n---\n${body}\n`, 'utf8') +} + +const FIXED_NOW = 1_700_000_000_000 + +type StubBundle = { + client: IAnalyticsClient + flushStub: sinon.SinonStub + trackStub: sinon.SinonStub +} + +const buildAnalyticsClient = (): StubBundle => { + const trackStub = sinon.stub() + const flushStub = sinon.stub().resolves(AnalyticsBatch.create([])) + const client: IAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: flushStub, + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + return {client, flushStub, trackStub} +} + +const buildCurateTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'client-1', + completedAt: FIXED_NOW + 5000, + content: 'curate stuff', + createdAt: FIXED_NOW, + projectPath: '/project', + taskId: 'task-curate-1', + type: 'curate', + ...overrides, + }) as TaskInfo + +const buildQueryTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'client-1', + completedAt: FIXED_NOW + 1234, + content: 'query stuff', + createdAt: FIXED_NOW, + projectPath: '/project', + taskId: 'task-query-1', + toolCalls: [], + type: 'query', + ...overrides, + }) as TaskInfo + +type Deferred = {promise: Promise; reject: (e: unknown) => void; resolve: (v: T) => void} +const defer = (): Deferred => { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return {promise, reject, resolve} +} + +const buildFrontmatterDoc = (tag: string): string => `---\ntags: ["${tag}"]\n---\nbody\n` + +const findEmit = (stub: sinon.SinonStub, event: string): Record => { + const call = stub.getCalls().find((c) => c.args[0] === event) + if (!call) throw new Error(`expected ${event} emit not found`) + return call.args[1] as Record +} + +const stubReadFileFromQueue = + (...queue: Array>): ((p: string) => Promise) => + () => { + const next = queue.shift() + if (next === undefined) throw new Error('stubReadFileFromQueue exhausted') + return next + } + +const stubReadFileAlways = + (value: Promise): ((p: string) => Promise) => + () => + value + +const buildToolResult = (ops: Array>): LlmToolResultEvent => ({ + callId: 'call-1', + result: JSON.stringify({applied: ops}), + sessionId: 'session-1', + taskId: 'task-curate-1', + timestamp: FIXED_NOW, + toolName: 'curate' as const, +}) as unknown as LlmToolResultEvent + +describe('AnalyticsHook', () => { + let trackStub: sinon.SinonStub + let hook: AnalyticsHook + + beforeEach(() => { + const bundle = buildAnalyticsClient() + trackStub = bundle.trackStub + hook = new AnalyticsHook() + hook.setAnalyticsClient(bundle.client) + }) + + // M14.3 added unconditional task_created / task_completed / task_failed + // emits on every lifecycle callback. Pre-M14.3 tests asserted only the + // M12 per-flavor curate_*/query_completed emits; filter the stub calls + // so existing assertions stay focused on M12 behavior. New M14.3 + // coverage lives in `analytics-hook-m14.test.ts`. + const filterM12 = (stub: sinon.SinonStub): sinon.SinonSpyCall[] => + stub.getCalls().filter((c) => { + const eventName = c.args[0] + return ( + eventName !== AnalyticsEventNames.TASK_CREATED && + eventName !== AnalyticsEventNames.TASK_COMPLETED && + eventName !== AnalyticsEventNames.TASK_FAILED + ) + }) + const m12Calls = (): sinon.SinonSpyCall[] => filterM12(trackStub) + + describe('curate task flow', () => { + it('emits curate_operation_applied per successful op + bumps matching counter; no event for failed op', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + + const payload = buildToolResult([ + {filePath: '/a.md', needsReview: false, path: 'notes/a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: true, path: 'notes/b', status: 'success', type: 'UPDATE'}, + {filePath: '/c.md', needsReview: false, path: 'notes/c', status: 'failed', type: 'ADD'}, + ]) + await hook.onToolResult(task.taskId, payload) + + expect(m12Calls()).to.have.lengthOf(2) + expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + const firstProps = m12Calls()[0].args[1] as Record + // buildCurateTask sets projectPath:'/project'; /a.md escapes the + // project root → bare PR #722 outside-project sentinel (the basename is + // dropped so an out-of-project filename never reaches the wire). The op + // stays identifiable via knowledge_path below. + expect(firstProps.relative_path).to.equal('') + expect(firstProps.knowledge_path).to.equal('notes/a') + expect(firstProps.operation_type).to.equal('ADD') + expect(firstProps.needs_review).to.equal(false) + expect(firstProps.tags).to.deep.equal([]) + expect(firstProps.keywords).to.deep.equal([]) + expect(firstProps).to.not.have.property('related') + + const secondProps = m12Calls()[1].args[1] as Record + expect(secondProps.needs_review).to.equal(true) + expect(secondProps.operation_type).to.equal('UPDATE') + }) + + it('emits curate_run_completed at terminal with counter totals + outcome=completed', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: false, path: 'b', status: 'success', type: 'UPDATE'}, + {filePath: '/c.md', needsReview: false, path: 'c', status: 'success', type: 'DELETE'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + expect(m12Calls()).to.have.lengthOf(1) + expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + const props = m12Calls()[0].args[1] as Record + expect(props.task_id).to.equal(task.taskId) + expect(props.task_type).to.equal('curate') + expect(props.outcome).to.equal('completed') + expect(props.operations_added).to.equal(1) + expect(props.operations_updated).to.equal(1) + expect(props.operations_deleted).to.equal(1) + expect(props.operations_merged).to.equal(0) + expect(props.operations_failed).to.equal(0) + expect(props.pending_review_count).to.equal(0) + expect(props.duration_ms).to.equal(5000) + }) + + it('emits outcome=partial when at least one op failed', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: false, path: 'b', status: 'failed', type: 'ADD'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.outcome).to.equal('partial') + expect(props.operations_failed).to.equal(1) + }) + + it('emits outcome=error on onTaskError', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + trackStub.resetHistory() + + await hook.onTaskError(task.taskId, 'boom', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.outcome).to.equal('error') + }) + + it('emits outcome=cancelled on onTaskCancelled', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + trackStub.resetHistory() + + await hook.onTaskCancelled(task.taskId, task) + + const props = m12Calls()[0].args[1] as Record + expect(props.outcome).to.equal('cancelled') + }) + + it('counts UPSERT with "created new" message as added; otherwise as updated', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', message: 'created new entry', path: 'a', status: 'success', type: 'UPSERT'}, + {filePath: '/b.md', message: 'updated existing entry', path: 'b', status: 'success', type: 'UPSERT'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.operations_added).to.equal(1) + expect(props.operations_updated).to.equal(1) + }) + + it('counts pending review when needsReview=true on a successful op', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', needsReview: true, path: 'a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: true, path: 'b', status: 'success', type: 'UPDATE'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.pending_review_count).to.equal(2) + }) + + it('uses task_type literal from task (curate-folder)', async () => { + const task = buildCurateTask({type: 'curate-folder'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.task_type).to.equal('curate-folder') + }) + + it('skips emitting op when op.filePath is missing (avoids invalid payload)', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + + // No curate_operation_applied for an op missing filePath. (TASK_CREATED + // still fires from onTaskCreate — filtered out via m12Calls().) + expect(m12Calls()).to.have.lengthOf(0) + }) + }) + + describe('query task flow', () => { + it('emits query_completed at terminal with derived counts + paths', async () => { + const task = buildQueryTask({ + toolCalls: [ + {args: {filePath: '/a.md'}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + {args: {filePath: '/b.md'}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, + {args: {filePath: '/a.md'}, sessionId: 's', status: 'completed', timestamp: 3, toolName: 'read_file'}, + { + args: {stubPath: '/c.md'}, + sessionId: 's', + status: 'completed', + timestamp: 4, + toolName: 'expand_knowledge', + }, + {args: {query: 'foo'}, sessionId: 's', status: 'completed', timestamp: 5, toolName: 'search_knowledge'}, + ], + } as Partial) + + await hook.onTaskCreate(task) + hook.setQueryResult(task.taskId, { + matchedDocs: [], + searchMetadata: {resultCount: 7, topScore: 0.9, totalFound: 7}, + tier: 3, + timing: {durationMs: 1234}, + } as QueryResultMetadata) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(m12Calls()).to.have.lengthOf(1) + expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.QUERY_COMPLETED) + const props = m12Calls()[0].args[1] as Record + expect(props.task_id).to.equal(task.taskId) + expect(props.task_type).to.equal('query') + expect(props.outcome).to.equal('completed') + expect(props.duration_ms).to.equal(1234) + expect(props.read_tool_call_count).to.equal(4) // 3 read_file + 1 expand_knowledge + expect(props.search_call_count).to.equal(1) + expect(props.read_doc_count).to.equal(3) // distinct: /a.md, /b.md, /c.md + expect(props.tier).to.equal(3) + expect(props.cache_hit).to.equal(false) + expect(props.matched_doc_count).to.equal(7) + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(3) + // sorted lexicographically; relativized against projectPath:'/project'. + // All three escape the root, so each collapses to the bare sentinel. + // The count (3) still distinguishes them — dedup is on the original + // absolute path, not on this relativized output. + expect(paths.map((p) => p.relative_path)).to.deep.equal([ + '', + '', + '', + ]) + // each entry has empty keywords/tags arrays and an empty related_paths + // list — no frontmatter source files exist in this in-memory test. + for (const entry of paths) { + expect(entry.tags).to.deep.equal([]) + expect(entry.keywords).to.deep.equal([]) + expect(entry.related_paths).to.deep.equal([]) + } + }) + + it('caps read_paths_with_metadata at 10 entries even when more distinct paths exist', async () => { + const toolCalls = Array.from({length: 15}, (_, i) => ({ + args: {filePath: `/file-${String(i).padStart(2, '0')}.md`}, + sessionId: 's', + status: 'completed' as const, + timestamp: i, + toolName: 'read_file', + })) + const task = buildQueryTask({toolCalls} as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(10) + expect(props.read_doc_count).to.equal(15) // distinct count NOT capped + }) + + for (const tier of [0, 1] as const) { + it(`cache_hit is true for tier ${tier}`, async () => { + const localBundle = buildAnalyticsClient() + const localHook = new AnalyticsHook() + localHook.setAnalyticsClient(localBundle.client) + const task = buildQueryTask({taskId: `task-tier-${tier}`}) + + await localHook.onTaskCreate(task) + localHook.setQueryResult(task.taskId, { + matchedDocs: [], + tier, + timing: {durationMs: 5}, + } as QueryResultMetadata) + await localHook.onTaskCompleted(task.taskId, '', task) + + const props = filterM12(localBundle.trackStub)[0].args[1] as Record + expect(props.cache_hit).to.equal(true) + }) + } + + for (const tier of [2, 3, 4] as const) { + it(`cache_hit is false for tier ${tier}`, async () => { + const localBundle = buildAnalyticsClient() + const localHook = new AnalyticsHook() + localHook.setAnalyticsClient(localBundle.client) + const task = buildQueryTask({taskId: `task-tier-${tier}`}) + + await localHook.onTaskCreate(task) + localHook.setQueryResult(task.taskId, { + matchedDocs: [], + tier, + timing: {durationMs: 5}, + } as QueryResultMetadata) + await localHook.onTaskCompleted(task.taskId, '', task) + + const props = filterM12(localBundle.trackStub)[0].args[1] as Record + expect(props.cache_hit).to.equal(false) + }) + } + + it('emits tier absent + cache_hit=false + matched_doc_count=0 when setQueryResult never ran', async () => { + const task = buildQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.tier).to.equal(undefined) + expect(props.cache_hit).to.equal(false) + expect(props.matched_doc_count).to.equal(0) + }) + + it('omits read_paths_with_metadata when the command had no read paths (matches optional schema)', async () => { + const task = buildQueryTask() // empty toolCalls + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + expect(props).to.not.have.property('read_paths_with_metadata') + // Sanity: counts are zero, not omitted. + expect(props.read_doc_count).to.equal(0) + expect(props.read_tool_call_count).to.equal(0) + }) + + it('emits outcome=error on onTaskError for query', async () => { + const task = buildQueryTask() + await hook.onTaskCreate(task) + + await hook.onTaskError(task.taskId, 'boom', task) + + const props = m12Calls()[0].args[1] as Record + expect(props.outcome).to.equal('error') + }) + + it('emits outcome=cancelled on onTaskCancelled for query', async () => { + const task = buildQueryTask() + await hook.onTaskCreate(task) + + await hook.onTaskCancelled(task.taskId, task) + + const props = m12Calls()[0].args[1] as Record + expect(props.outcome).to.equal('cancelled') + }) + }) + + describe('lifecycle hygiene', () => { + it('cleanup(taskId) drops state for both flavors', async () => { + const curate = buildCurateTask() + const query = buildQueryTask() + await hook.onTaskCreate(curate) + await hook.onTaskCreate(query) + hook.cleanup(curate.taskId) + hook.cleanup(query.taskId) + + // After cleanup, M12 per-flavor emits must NOT fire (no state to read). + // M14.3 generic TASK_COMPLETED still fires unconditionally — filtered. + trackStub.resetHistory() + await hook.onTaskCompleted(curate.taskId, '', curate) + await hook.onTaskCompleted(query.taskId, '', query) + expect(m12Calls()).to.have.lengthOf(0) + }) + + it('ignores unknown task types (no M12 state created; only generic task_* emits fire)', async () => { + const task = buildCurateTask({taskId: 'task-unknown', type: 'unknown' as TaskInfo['type']}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + expect(m12Calls()).to.have.lengthOf(0) + }) + + it('swallows analyticsClient.track throws (does not propagate)', async () => { + trackStub.throws(new Error('boom')) + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + // No throw means swallowed + expect(trackStub.called).to.equal(true) + }) + + it('emit is a no-op when setAnalyticsClient was never called (originally curate emit)', async () => { + const bareHook = new AnalyticsHook() + const task = buildCurateTask() + await bareHook.onTaskCreate(task) + // No throws, no client to assert against + await bareHook.onToolResult( + task.taskId, + buildToolResult([{filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + await bareHook.onTaskCompleted(task.taskId, '', task) + }) + }) + + describe('M12.3 frontmatter harvest', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'analytics-hook-')) + }) + + afterEach(() => { + rmSync(tmpDir, {force: true, recursive: true}) + }) + + describe('curate emit', () => { + it('attaches tags/keywords/related from post-op frontmatter on ADD ops', async () => { + const filePath = join(tmpDir, 'a.md') + writeMarkdown(filePath, {keywords: ['x', 'y'], related: ['z'], tags: ['t1', 't2']}) + + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal(['t1', 't2']) + expect(props.keywords).to.deep.equal(['x', 'y']) + expect(props.related).to.deep.equal(['z']) + }) + + it('keywords/tags default to empty arrays on DELETE ops (file gone post-op); related stays omitted', async () => { + const filePath = join(tmpDir, 'gone.md') + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'gone', status: 'success', type: 'DELETE'}]), + ) + + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) + expect(props).to.not.have.property('related') + }) + + it('keywords/tags default to empty arrays when filePath cannot be read (ENOENT)', async () => { + const filePath = join(tmpDir, 'missing.md') + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'm', status: 'success', type: 'UPDATE'}]), + ) + + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) + }) + + it('keywords/tags default to empty arrays on malformed YAML (no throw)', async () => { + const filePath = join(tmpDir, 'bad.md') + writeFileSync(filePath, '---\nthis is: not [valid YAML\n---\nbody', 'utf8') + + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'b', status: 'success', type: 'UPDATE'}]), + ) + + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) + }) + + it('caps arrays at 50 entries and strings at 256 chars per entry', async () => { + const filePath = join(tmpDir, 'huge.md') + const overlong = 'x'.repeat(300) + const sixtyTags = Array.from({length: 60}, (_, i) => `tag-${i}`) + writeMarkdown(filePath, {tags: [overlong, ...sixtyTags]}) + + const task = buildCurateTask() + await hook.onTaskCreate(task) + await hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'h', status: 'success', type: 'UPDATE'}]), + ) + + const props = m12Calls()[0].args[1] as Record + const tags = props.tags as string[] + expect(tags).to.have.lengthOf(50) + expect(tags[0]).to.have.lengthOf(256) + }) + + it('skips file reads entirely when isEnabled() returns false; keywords/tags fall back to []', async () => { + const filePath = join(tmpDir, 'gated.md') + writeMarkdown(filePath, {tags: ['should-not-appear']}) + + const disabledBundle = buildAnalyticsClient() + const disabledHook = new AnalyticsHook({isEnabled: () => false}) + disabledHook.setAnalyticsClient(disabledBundle.client) + const task = buildCurateTask({taskId: 'task-gated'}) + + await disabledHook.onTaskCreate(task) + await disabledHook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'g', status: 'success', type: 'UPDATE'}]), + ) + + const props = filterM12(disabledBundle.trackStub)[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) + }) + }) + + describe('query emit', () => { + it('attaches per-path frontmatter to read_paths_with_metadata entries', async () => { + const a = join(tmpDir, 'a.md') + const b = join(tmpDir, 'b.md') + writeMarkdown(a, {tags: ['ta']}) + writeMarkdown(b, {keywords: ['kb']}) + + // Pin projectPath to tmpDir so relative_path == 'a.md' / 'b.md'. + const task = buildQueryTask({ + projectPath: tmpDir, + toolCalls: [ + {args: {filePath: a}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + {args: {filePath: b}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, + ], + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + const paths = props.read_paths_with_metadata as Array> + const byPath = Object.fromEntries(paths.map((p) => [p.relative_path, p])) + expect(byPath['a.md'].tags).to.deep.equal(['ta']) + expect(byPath['a.md'].keywords).to.deep.equal([]) + expect(byPath['b.md'].keywords).to.deep.equal(['kb']) + expect(byPath['b.md'].tags).to.deep.equal([]) + }) + + it('mixed readable + ENOENT paths: each entry has keywords/tags arrays (populated or empty)', async () => { + const real = join(tmpDir, 'real.md') + const missing = join(tmpDir, 'missing.md') + writeMarkdown(real, {tags: ['ok']}) + + const task = buildQueryTask({ + projectPath: tmpDir, + toolCalls: [ + {args: {filePath: real}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + {args: {filePath: missing}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, + ], + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = m12Calls()[0].args[1] as Record + const paths = props.read_paths_with_metadata as Array> + const byPath = Object.fromEntries(paths.map((p) => [p.relative_path, p])) + expect(byPath['real.md'].tags).to.deep.equal(['ok']) + expect(byPath['missing.md'].tags).to.deep.equal([]) + expect(byPath['missing.md'].keywords).to.deep.equal([]) + }) + + it('skips per-path file reads when isEnabled() returns false', async () => { + const filePath = join(tmpDir, 'gated-query.md') + writeMarkdown(filePath, {tags: ['should-not-appear']}) + + const disabledBundle = buildAnalyticsClient() + const disabledHook = new AnalyticsHook({isEnabled: () => false}) + disabledHook.setAnalyticsClient(disabledBundle.client) + + const task = buildQueryTask({ + taskId: 'task-q-gated', + toolCalls: [ + {args: {filePath}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + ], + } as Partial) + + await disabledHook.onTaskCreate(task) + await disabledHook.onTaskCompleted(task.taskId, '', task) + + const props = filterM12(disabledBundle.trackStub)[0].args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths[0].tags).to.deep.equal([]) + expect(paths[0].keywords).to.deep.equal([]) + }) + }) + }) + + describe('async safety (per-task serialization)', () => { + it('serializes concurrent onToolResult calls for the same task in arrival order', async () => { + // Without the per-task queue, resolving the 2nd read first would cause op2's emit + // to land before op1. The queue must enforce arrival order regardless of read + // completion order. + const d1 = defer() + const d2 = defer() + const stubReadFile = stubReadFileFromQueue(d1.promise, d2.promise) + + const bundle = buildAnalyticsClient() + const queueHook = new AnalyticsHook({readFile: stubReadFile}) + queueHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-queue-1'}) + await queueHook.onTaskCreate(task) + + const payload1 = buildToolResult([ + {filePath: '/op1.md', needsReview: false, path: 'notes/op1', status: 'success', type: 'ADD'}, + ]) + const payload2 = buildToolResult([ + {filePath: '/op2.md', needsReview: false, path: 'notes/op2', status: 'success', type: 'ADD'}, + ]) + + const p1 = queueHook.onToolResult(task.taskId, payload1) + const p2 = queueHook.onToolResult(task.taskId, payload2) + + // Resolve in reverse order — the queue must still emit op1 first. + d2.resolve(buildFrontmatterDoc('tag-op2')) + d1.resolve(buildFrontmatterDoc('tag-op1')) + + await Promise.all([p1, p2]) + + expect(filterM12(bundle.trackStub)).to.have.lengthOf(2) + const first = filterM12(bundle.trackStub)[0].args[1] as Record + const second = filterM12(bundle.trackStub)[1].args[1] as Record + // Both files escape projectPath:'/project', so relative_path collapses + // to the same bare sentinel for both — distinguish the ops by their + // knowledge_path instead to prove arrival-order emit (op1 before op2). + expect(first.knowledge_path, 'first emit must be op1').to.equal('notes/op1') + expect(second.knowledge_path, 'second emit must be op2').to.equal('notes/op2') + }) + + it('onTaskCompleted waits for in-flight onToolResult work before emitting CURATE_RUN_COMPLETED', async () => { + // The terminal emit MUST follow every per-op emit on the wire, even if the per-op + // read is still pending when onTaskCompleted fires. + const d = defer() + const stubReadFile = stubReadFileAlways(d.promise) + const bundle = buildAnalyticsClient() + const orderHook = new AnalyticsHook({readFile: stubReadFile}) + orderHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-order-1'}) + await orderHook.onTaskCreate(task) + + const payload = buildToolResult([ + {filePath: '/in-flight.md', needsReview: false, path: 'notes/x', status: 'success', type: 'ADD'}, + ]) + + // Kick off the op processing (read pending), then immediately request terminal. + const opPromise = orderHook.onToolResult(task.taskId, payload) + const completePromise = orderHook.onTaskCompleted(task.taskId, '', task) + + // Neither M12 emit can have fired yet — read is still pending. (TASK_CREATED + // from M14.3 already fired during onTaskCreate but doesn't gate on the read.) + expect(filterM12(bundle.trackStub), 'no M12 emit before read settles').to.have.lengthOf(0) + + d.resolve(buildFrontmatterDoc('tag-x')) + await Promise.all([opPromise, completePromise]) + + expect(filterM12(bundle.trackStub)).to.have.lengthOf(2) + expect(filterM12(bundle.trackStub)[0].args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(filterM12(bundle.trackStub)[1].args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + }) + + it('readFile rejection is swallowed: emit fires with frontmatter fields omitted; daemon does not crash', async () => { + const stubReadFile = stubReadFileAlways(Promise.reject(new Error('disk full'))) + const bundle = buildAnalyticsClient() + const errorHook = new AnalyticsHook({readFile: stubReadFile}) + errorHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-err-1'}) + await errorHook.onTaskCreate(task) + + const payload = buildToolResult([ + {filePath: '/missing.md', needsReview: false, path: 'notes/missing', status: 'success', type: 'ADD'}, + ]) + + await errorHook.onToolResult(task.taskId, payload) + + expect(filterM12(bundle.trackStub)).to.have.lengthOf(1) + const props = filterM12(bundle.trackStub)[0].args[1] as Record + // /missing.md escapes the '/project' root — bare PR #722 outside-project + // sentinel (basename dropped). + expect(props.relative_path).to.equal('') + expect(props.keywords).to.deep.equal([]) + expect(props.tags).to.deep.equal([]) + expect(props).to.not.have.property('related') + }) + + it('cleanup removes per-task pending-queue entry to prevent unbounded growth', async () => { + const stubReadFile = stubReadFileAlways(Promise.resolve('---\n---\n')) + const bundle = buildAnalyticsClient() + const cleanupHook = new AnalyticsHook({readFile: stubReadFile}) + cleanupHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-cleanup-1'}) + await cleanupHook.onTaskCreate(task) + await cleanupHook.onToolResult(task.taskId, buildToolResult([ + {filePath: '/x.md', needsReview: false, path: 'notes/x', status: 'success', type: 'ADD'}, + ])) + await cleanupHook.onTaskCompleted(task.taskId, '', task) + cleanupHook.cleanup(task.taskId) + + // After cleanup, internal state must be empty. We don't expose pendingByTask + // directly, but the assertion below catches the leak: a new task with the same + // id observes a fresh in-memory state. + await cleanupHook.onTaskCreate(task) + // M12 emits: 1 curate_operation_applied + 1 curate_run_completed = 2. + // Re-creating the task after cleanup must NOT replay either; it only + // adds another TASK_CREATED (filtered out below). + expect(filterM12(bundle.trackStub), 'no replay after cleanup').to.have.lengthOf(2) + }) + }) + + describe('identity stamping (space_id + team_id)', () => { + it('stamps both space_id and team_id on curate_run_completed when getIdentity returns them', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-abc', teamId: 'team-abc'})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps.space_id).to.equal('space-abc') + expect(curateProps.team_id).to.equal('team-abc') + }) + + it('stamps both space_id and team_id on query_completed when getIdentity returns them', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-xyz', teamId: 'team-xyz'})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildQueryTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) + expect(queryProps.space_id).to.equal('space-xyz') + expect(queryProps.team_id).to.equal('team-xyz') + }) + + it('stamps team_id alone when spaceId is absent (mid-onboarding state)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({teamId: 'team-only'})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps.team_id).to.equal('team-only') + expect(curateProps).to.not.have.property('space_id') + }) + + it('stamps space_id alone when teamId is absent', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-only'})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildQueryTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) + expect(queryProps.space_id).to.equal('space-only') + expect(queryProps).to.not.have.property('team_id') + }) + + it('omits both fields when getIdentity returns {} (standalone project)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + expect(curateProps).to.not.have.property('team_id') + }) + + it('normalizes empty strings to omitted fields', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: '', teamId: ''})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildQueryTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) + expect(queryProps).to.not.have.property('space_id') + expect(queryProps).to.not.have.property('team_id') + }) + + it('omits both fields and still emits when getIdentity throws', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({ + async getIdentity() { + throw new Error('config disk unreadable') + }, + }) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + expect(curateProps).to.not.have.property('team_id') + // Funnel emit still lands — getIdentity failure must not block the run-completion emit. + expect(curateProps.task_type).to.equal('curate') + }) + + it('also stamps both fields on the failure-path emits (onTaskError)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-fail', teamId: 'team-fail'})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskError(task.taskId, 'boom', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps.outcome).to.equal('error') + expect(curateProps.space_id).to.equal('space-fail') + expect(curateProps.team_id).to.equal('team-fail') + }) + + it('does not invoke getIdentity when analytics is disabled (short-circuit)', async () => { + const bundle = buildAnalyticsClient() + let calls = 0 + const spacedHook = new AnalyticsHook({ + async getIdentity() { + calls++ + return {spaceId: 'should-not-stamp', teamId: 'should-not-stamp'} + }, + isEnabled: () => false, + }) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + expect(calls, 'getIdentity skipped when analytics disabled').to.equal(0) + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + expect(curateProps).to.not.have.property('team_id') + }) + }) +}) diff --git a/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts b/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts new file mode 100644 index 000000000..9011f769d --- /dev/null +++ b/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts @@ -0,0 +1,289 @@ +/* eslint-disable camelcase */ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {expect} from 'chai' +import sinon from 'sinon' + +import type {CurateLogOperation} from '../../../../../src/server/core/domain/entities/curate-log-entry.js' +import type { + QueryToolModeMatchedDoc, + QueryToolModeMetadata, +} from '../../../../../src/server/core/interfaces/executor/i-query-executor.js' + +import {LlmEventNames} from '../../../../../src/server/core/domain/transport/schemas.js' +import { + emitSyntheticCurateToolResult, + emitSyntheticQueryToolCalls, +} from '../../../../../src/server/infra/process/synthetic-tool-result-emit.js' +import {extractCurateOperations} from '../../../../../src/server/utils/curate-result-parser.js' + +const buildTransport = (): {requestStub: sinon.SinonStub; transport: ITransportClient} => { + const requestStub = sinon.stub().resolves() + const transport = {request: requestStub} as unknown as ITransportClient + return {requestStub, transport} +} + +const buildMatchedDoc = (overrides: Partial = {}): QueryToolModeMatchedDoc => ({ + format: 'markdown', + path: 'topics/intro.md', + rendered_md: '## stub', + score: 0.5, + title: 'Intro', + ...overrides, +}) + +const buildMetadata = (overrides: Partial = {}): QueryToolModeMetadata => ({ + cacheHit: null, + durationMs: 12, + skippedSharedCount: 0, + tier: 2, + topScore: 0.72, + totalFound: 1, + ...overrides, +}) + +describe('synthetic-tool-result-emit (M17 tool-mode gap fix)', () => { + describe('emitSyntheticCurateToolResult', () => { + it('dispatches an llmservice:toolResult that round-trips through extractCurateOperations', () => { + const {requestStub, transport} = buildTransport() + const operations: CurateLogOperation[] = [ + { + confidence: 'high', + filePath: '/proj/.brv/context-tree/topic.html', + impact: 'high', + needsReview: true, + path: 'analytics/topic', + status: 'success', + type: 'ADD', + }, + ] + + emitSyntheticCurateToolResult({operations, taskId: 'task-1', transport}) + + expect(requestStub.calledOnce).to.equal(true) + const [eventName, payload] = requestStub.firstCall.args as [string, Record] + expect(eventName).to.equal(LlmEventNames.TOOL_RESULT) + expect(payload.toolName).to.equal('curate') + expect(payload.success).to.equal(true) + expect(payload.taskId).to.equal('task-1') + // M17: marker tells TaskRouter to skip the per-client broadcast so + // synthetic envelopes never surface in CLI / TUI / MCP / webui. + expect(payload.metadata).to.deep.equal({_synthetic: true}) + + // The result must round-trip through the parser AnalyticsHook uses, + // otherwise the synthetic envelope is dead-on-arrival downstream. + const parsed = extractCurateOperations({ + result: payload.result as string, + toolName: 'curate', + }) + expect(parsed).to.have.length(1) + expect(parsed[0]).to.deep.include({ + filePath: '/proj/.brv/context-tree/topic.html', + path: 'analytics/topic', + status: 'success', + type: 'ADD', + }) + }) + + it('skips emit when operations array is empty', () => { + const {requestStub, transport} = buildTransport() + emitSyntheticCurateToolResult({operations: [], taskId: 'task-1', transport}) + expect(requestStub.called).to.equal(false) + }) + + it('preserves a failed op so curate_run_completed.operations_failed bumps correctly', () => { + const {requestStub, transport} = buildTransport() + const operations: CurateLogOperation[] = [ + {path: 'analytics/topic', status: 'failed', type: 'ADD'}, + ] + + emitSyntheticCurateToolResult({operations, taskId: 'task-1', transport}) + + expect(requestStub.calledOnce).to.equal(true) + const parsed = extractCurateOperations({ + result: requestStub.firstCall.args[1].result, + toolName: 'curate', + }) + expect(parsed[0].status).to.equal('failed') + }) + + it('swallows synchronous transport throws and logs', () => { + const requestStub = sinon.stub().throws(new Error('boom')) + const transport = {request: requestStub} as unknown as ITransportClient + const logStub = sinon.stub() + + expect(() => + emitSyntheticCurateToolResult({ + log: logStub, + operations: [{path: 'x', status: 'success', type: 'ADD'}], + taskId: 'task-1', + transport, + }), + ).to.not.throw() + expect(logStub.calledOnce).to.equal(true) + expect(logStub.firstCall.args[0]).to.include('synthetic curate toolResult emit failed') + expect(logStub.firstCall.args[0]).to.include('sync throw') + }) + + it('does not let a throwing log callback escape on the sync-throw path', () => { + const requestStub = sinon.stub().throws(new Error('boom')) + const transport = {request: requestStub} as unknown as ITransportClient + const throwingLog = sinon.stub().throws(new Error('log sink exploded')) + + expect(() => + emitSyntheticCurateToolResult({ + log: throwingLog, + operations: [{path: 'x', status: 'success', type: 'ADD'}], + taskId: 'task-1', + transport, + }), + ).to.not.throw() + expect(throwingLog.calledOnce, 'the log sink was invoked (and its throw swallowed)').to.equal(true) + }) + + it('swallows async transport rejections and logs (PR #728 review fix)', async () => { + const requestStub = sinon.stub().rejects(new Error('socket dead')) + const transport = {request: requestStub} as unknown as ITransportClient + const logStub = sinon.stub() + + emitSyntheticCurateToolResult({ + log: logStub, + operations: [{path: 'x', status: 'success', type: 'ADD'}], + taskId: 'task-1', + transport, + }) + + // Async rejection runs on the microtask queue — yield once so the + // catch handler fires before we assert. Without this, the log + // assertion races the rejection. + await new Promise((res) => { + setImmediate(res) + }) + + expect(logStub.calledOnce).to.equal(true) + expect(logStub.firstCall.args[0]).to.include('synthetic curate toolResult emit failed') + expect(logStub.firstCall.args[0]).to.include('async rejection') + }) + }) + + describe('emitSyntheticQueryToolCalls', () => { + it('fires paired toolCall+toolResult for search_knowledge + one pair per matched doc (PR #728 review fix)', () => { + const {requestStub, transport} = buildTransport() + + emitSyntheticQueryToolCalls({ + matchedDocs: [ + buildMatchedDoc({path: 'a.md'}), + buildMatchedDoc({path: 'b.md'}), + ], + metadata: buildMetadata({totalFound: 2}), + projectPath: '/proj', + taskId: 'task-q', + transport, + }) + + // 1 search_knowledge toolCall + 1 toolResult, then 2 docs × (call+result) = 6. + // The pair is what flips the accumulator's `status: 'running'` to + // `'completed'` in `TaskRouter.accumulateLlmEvent`; without it the + // synthetic call would be stuck running for the task's lifetime. + expect(requestStub.callCount).to.equal(6) + + // Every emission carries the synthetic marker so TaskRouter skips the + // per-client broadcast (M17 — see SYNTHETIC_EVENT_METADATA docblock). + for (const call of requestStub.getCalls()) { + expect(call.args[1].metadata).to.deep.equal({_synthetic: true}) + } + + // Call/result pairs share a callId so the accumulator matches them. + const calls = requestStub.getCalls() + const search = {call: calls[0], result: calls[1]} + expect(search.call.args[0]).to.equal(LlmEventNames.TOOL_CALL) + expect(search.call.args[1].toolName).to.equal('search_knowledge') + expect(search.result.args[0]).to.equal(LlmEventNames.TOOL_RESULT) + expect(search.result.args[1].toolName).to.equal('search_knowledge') + expect(search.result.args[1].callId).to.equal(search.call.args[1].callId) + expect(search.result.args[1].success).to.equal(true) + + // Per-doc pairs: one call + one result per matched doc, callIds aligned. + const readPairs = [ + {call: calls[2], result: calls[3]}, + {call: calls[4], result: calls[5]}, + ] + for (const [i, pair] of readPairs.entries()) { + expect(pair.call.args[0]).to.equal(LlmEventNames.TOOL_CALL) + expect(pair.call.args[1].toolName).to.equal('read_file') + expect(pair.call.args[1].args.filePath).to.equal( + ['/proj/.brv/context-tree/a.md', '/proj/.brv/context-tree/b.md'][i], + ) + expect(pair.result.args[0]).to.equal(LlmEventNames.TOOL_RESULT) + expect(pair.result.args[1].toolName).to.equal('read_file') + expect(pair.result.args[1].callId).to.equal(pair.call.args[1].callId) + expect(pair.result.args[1].success).to.equal(true) + } + }) + + it('emits only the search_knowledge call+result pair when no docs matched', () => { + const {requestStub, transport} = buildTransport() + + emitSyntheticQueryToolCalls({ + matchedDocs: [], + metadata: buildMetadata({totalFound: 0}), + projectPath: '/proj', + taskId: 'task-q', + transport, + }) + + expect(requestStub.callCount).to.equal(2) + expect(requestStub.getCall(0).args[0]).to.equal(LlmEventNames.TOOL_CALL) + expect(requestStub.getCall(0).args[1].toolName).to.equal('search_knowledge') + expect(requestStub.getCall(0).args[1].args.matchedCount).to.equal(0) + expect(requestStub.getCall(1).args[0]).to.equal(LlmEventNames.TOOL_RESULT) + expect(requestStub.getCall(1).args[1].callId).to.equal(requestStub.getCall(0).args[1].callId) + }) + + it('does NOT leak the raw query string into args (privacy guard)', () => { + const {requestStub, transport} = buildTransport() + + emitSyntheticQueryToolCalls({ + matchedDocs: [buildMatchedDoc()], + metadata: buildMetadata(), + projectPath: '/proj', + taskId: 'task-q', + transport, + }) + + for (const call of requestStub.getCalls()) { + // toolCall envelopes carry `args`; toolResult envelopes carry `result`. + // Either way, the raw query string MUST NOT appear anywhere in the payload. + const payload = call.args[1] as Record + const args = payload.args as Record | undefined + if (args) expect(args).to.not.have.property('query') + // The result string also MUST NOT carry it. + if (typeof payload.result === 'string') { + expect(payload.result.toLowerCase()).to.not.include('query') + } + } + }) + + it('swallows synchronous transport throws and logs (per emit site)', () => { + const requestStub = sinon.stub().throws(new Error('socket dead')) + const transport = {request: requestStub} as unknown as ITransportClient + const logStub = sinon.stub() + + expect(() => + emitSyntheticQueryToolCalls({ + log: logStub, + matchedDocs: [], + metadata: buildMetadata(), + projectPath: '/proj', + taskId: 'task-q', + transport, + }), + ).to.not.throw() + // No-docs case fires 2 emits (search call + search result); both + // throw → both get logged independently via safeDispatch. + expect(logStub.callCount).to.equal(2) + expect(logStub.firstCall.args[0]).to.include('search_knowledge') + expect(logStub.firstCall.args[0]).to.include('sync throw') + }) + }) +}) diff --git a/test/unit/server/infra/transport/cli-invocation-middleware.test.ts b/test/unit/server/infra/transport/cli-invocation-middleware.test.ts new file mode 100644 index 000000000..0b99a0535 --- /dev/null +++ b/test/unit/server/infra/transport/cli-invocation-middleware.test.ts @@ -0,0 +1,187 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {attachCliInvocationMiddleware} from '../../../../../src/server/infra/transport/cli-invocation-middleware.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +function makeStubTransport(sandbox: SinonSandbox): { + requestHandlers: Map + transport: ITransportServer +} { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function validCliMetadata(overrides: Record = {}): Record { + return { + client_sent_at: 1_700_000_000_000, + command_id: 'status', + flag_names: ['format'], + is_ci: false, + is_tty: true, + package_manager: 'npm', + runtime: 'node', + ...overrides, + } +} + +describe('attachCliInvocationMiddleware (M15.8 §4)', () => { + let sandbox: SinonSandbox + let transportHelper: ReturnType + let trackStub: SinonStub + let analyticsClient: IAnalyticsClient + + beforeEach(() => { + sandbox = createSandbox() + transportHelper = makeStubTransport(sandbox) + trackStub = sandbox.stub() + analyticsClient = { + abort: sandbox.stub(), + flush: sandbox.stub().resolves({events: []}), + getRuntimeState: sandbox.stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sandbox.stub().resolves(), + track: trackStub, + } as unknown as IAnalyticsClient + }) + + afterEach(() => sandbox.restore()) + + it('fires cli_invocation exactly once per incoming request when cli_metadata is valid', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + await handler({cli_metadata: validCliMetadata(), cwd: '/proj'}, 'client-1') + + expect(trackStub.calledOnce).to.equal(true) + const trackArgs = trackStub.firstCall.args + expect(trackArgs[0]).to.equal(AnalyticsEventNames.CLI_INVOCATION) + const props = trackArgs[1] as Record + expect(props.command_id).to.equal('status') + expect(props.flag_names).to.deep.equal(['format']) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('does NOT emit when cli_metadata is absent', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('daemon:state', realHandler) + const handler = transportHelper.requestHandlers.get('daemon:state')! + + await handler({cwd: '/proj'}, 'client-1') + + expect(trackStub.called).to.equal(false) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('does NOT emit when cli_metadata is malformed (safeParse fails) but still forwards', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + // Missing required field (`runtime`) + await handler({cli_metadata: {command_id: 'partial'}, cwd: '/proj'}, 'client-1') + + expect(trackStub.called).to.equal(false) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('still emits when the underlying handler rejects ("user typed the command" funnel)', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().rejects(new Error('boom')) + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + try { + await handler({cli_metadata: validCliMetadata()}, 'client-1') + } catch { + /* error propagates from handler; expected */ + } + + expect(trackStub.calledOnce).to.equal(true) + }) + + it('is a no-op when no analytics client has been resolved yet (boot-time race)', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => undefined}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + await handler({cli_metadata: validCliMetadata()}, 'client-1') + + expect(trackStub.called).to.equal(false) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('swallows track() errors so analytics can never crash a real handler', async () => { + trackStub.throws(new Error('analytics down')) + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + const response = await handler({cli_metadata: validCliMetadata()}, 'client-1') + expect(response).to.equal('ok') + }) + + it('does NOT double-fire when middleware is applied once and multiple handlers register', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + transportHelper.transport.onRequest('a:event', sandbox.stub().resolves('a')) + transportHelper.transport.onRequest('b:event', sandbox.stub().resolves('b')) + + await transportHelper.requestHandlers.get('a:event')!({cli_metadata: validCliMetadata()}, 'c') + expect(trackStub.callCount).to.equal(1) + + await transportHelper.requestHandlers.get('b:event')!({cli_metadata: validCliMetadata()}, 'c') + expect(trackStub.callCount).to.equal(2) + }) + + it('idempotent attach: applying the middleware twice does NOT double-fire cli_invocation', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + // Second attach must be a no-op — without the guard, the wrapped onRequest + // would wrap itself, double-firing on every incoming request. + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + await handler({cli_metadata: validCliMetadata()}, 'client-1') + + expect(trackStub.callCount).to.equal(1) + expect(realHandler.calledOnce).to.equal(true) + }) +}) diff --git a/test/unit/server/infra/transport/client-kind-context.test.ts b/test/unit/server/infra/transport/client-kind-context.test.ts new file mode 100644 index 000000000..9086a451e --- /dev/null +++ b/test/unit/server/infra/transport/client-kind-context.test.ts @@ -0,0 +1,67 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + clientKindContext, + getClientKindFromContext, + runWithClientKind, +} from '../../../../../src/server/infra/transport/client-kind-context.js' + +describe('clientKindContext', () => { + describe('outside any scope', () => { + it('returns undefined from getClientKindFromContext()', () => { + expect(getClientKindFromContext()).to.equal(undefined) + }) + }) + + describe('runWithClientKind', () => { + it('exposes the wrapped value inside the callback', async () => { + const observed = await runWithClientKind('cli', async () => getClientKindFromContext()) + expect(observed).to.equal('cli') + }) + + it('returns the callback result', async () => { + const result = await runWithClientKind('webui', async () => 42) + expect(result).to.equal(42) + }) + + it('propagates value through an await boundary', async () => { + const observed = await runWithClientKind('tui', async () => { + await Promise.resolve() + return getClientKindFromContext() + }) + expect(observed).to.equal('tui') + }) + + it('isolates sibling scopes', async () => { + const [a, b] = await Promise.all([ + runWithClientKind('cli', async () => { + await Promise.resolve() + return getClientKindFromContext() + }), + runWithClientKind('webui', async () => { + await Promise.resolve() + return getClientKindFromContext() + }), + ]) + expect(a).to.equal('cli') + expect(b).to.equal('webui') + }) + + it('does not leak into the outer scope after the callback resolves', async () => { + await runWithClientKind('mcp', async () => {}) + expect(getClientKindFromContext()).to.equal(undefined) + }) + }) + + describe('clientKindContext (raw AsyncLocalStorage export)', () => { + it('is the same store that runWithClientKind wraps', async () => { + const observed = await new Promise((resolve) => { + clientKindContext.run({client_kind: 'extension'}, () => { + resolve(getClientKindFromContext()) + }) + }) + expect(observed).to.equal('extension') + }) + }) +}) diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts new file mode 100644 index 000000000..a31471dfd --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -0,0 +1,331 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {IAnalyticsClient} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {AnalyticsEventName} from '../../../../../../src/shared/analytics/event-names.js' +import type {PropsArg} from '../../../../../../src/shared/analytics/events/index.js' + +import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-handler.js' +import {AnalyticsEventNames} from '../../../../../../src/shared/analytics/event-names.js' +import {AnalyticsEvents, type AnalyticsTrackPayload} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +type AnalyticsTrackHandler = (data: unknown, clientId: string) => Promise + +type TrackCall = {event: AnalyticsEventName; properties: unknown} + +type MockAnalyticsClient = IAnalyticsClient & { + readonly trackCalls: readonly TrackCall[] + trackThrows?: Error +} + +/** + * Hand-rolled mock that preserves the generic signature on `track`. Sinon's + * `stub()` collapses generics into a single SinonStub overload, which fails + * to satisfy `IAnalyticsClient.track`. + */ +function makeMockAnalyticsClient(): MockAnalyticsClient { + const trackCalls: TrackCall[] = [] + const mock: MockAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: () => Promise.resolve(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: () => Promise.resolve(), + track(event: E, ...rest: PropsArg): void { + if (mock.trackThrows) throw mock.trackThrows + const [properties] = rest + trackCalls.push({event, properties}) + }, + trackCalls, + } + return mock +} + +describe('AnalyticsHandler', () => { + it('should register a handler for analytics:track on setup()', () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + + new AnalyticsHandler({analyticsClient, transport}).setup() + + expect(transport._handlers.has(AnalyticsEvents.TRACK)).to.equal(true) + }) + + describe('per-event Zod validation + typed dispatch', () => { + it('should route a valid known event + valid properties to analyticsClient.track', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + const payload: AnalyticsTrackPayload = { + event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, + properties: { + keywords: [], + knowledge_path: 'kg/a.md', + needs_review: false, + operation_type: 'ADD', + relative_path: 'tmp/a.md', + tags: [], + task_id: 't-1', + }, + } + await handler(payload, 'client-1') + + expect(analyticsClient.trackCalls).to.have.lengthOf(1) + expect(analyticsClient.trackCalls[0].event).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(analyticsClient.trackCalls[0].properties).to.deep.equal({ + keywords: [], + knowledge_path: 'kg/a.md', + needs_review: false, + operation_type: 'ADD', + relative_path: 'tmp/a.md', + tags: [], + task_id: 't-1', + }) + }) + + it('should route DAEMON_START (no required properties) without forwarding props', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: AnalyticsEventNames.DAEMON_START}, 'client-1') + + expect(analyticsClient.trackCalls).to.have.lengthOf(1) + expect(analyticsClient.trackCalls[0].event).to.equal(AnalyticsEventNames.DAEMON_START) + // PropsArg makes properties absent for events with no required props. + expect(analyticsClient.trackCalls[0].properties).to.equal(undefined) + }) + + it('should drop UNKNOWN event names silently (no track call)', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: 'cli_invocation', properties: {x: 1}}, 'client-1') + await handler({event: 'mystery_event'}, 'client-1') + + expect(analyticsClient.trackCalls, 'unknown events must NOT reach track').to.deep.equal([]) + }) + + it('should drop KNOWN events with INVALID per-event properties silently', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + // CURATE_OPERATION_APPLIED requires relative_path / knowledge_path / etc. + await handler({event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, properties: {wrong: 'shape'}}, 'client-1') + // QUERY_COMPLETED requires duration_ms / outcome / etc. + await handler({event: AnalyticsEventNames.QUERY_COMPLETED, properties: {}}, 'client-1') + + expect(analyticsClient.trackCalls, 'invalid per-event props must NOT reach track').to.deep.equal([]) + }) + }) + + it('should drop invalid wire envelope silently (no throw, no track call)', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + + await handler({event: ''}, 'client-1') + await handler({properties: {x: 1}}, 'client-1') + await handler({event: 42}, 'client-1') + await handler(null, 'client-1') + + expect(analyticsClient.trackCalls, 'track must NOT be called for invalid envelopes').to.deep.equal([]) + }) + + it('should not throw when analyticsClient.track itself throws', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + analyticsClient.trackThrows = new Error('boom') + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + + let caught: unknown + try { + await handler({event: AnalyticsEventNames.DAEMON_START}, 'client-1') + } catch (error) { + caught = error + } + + expect(caught, 'handler must NOT propagate track() errors').to.equal(undefined) + }) + + /** + * Regression coverage for every per-event `case` branch in `dispatch()`. + * The base tests above cover the dispatch PATTERN via one sample event; + * if a future refactor drops a `case` branch the event would fall + * through silently (no error, no track call). This parameterized test + * exercises every catalog event with a minimal valid payload and asserts + * the dispatch reaches `track()`. + */ + describe('per-event dispatch coverage — every new event name reaches track()', () => { + const validHashHex = 'a'.repeat(64) + // Per-event minimal payloads that satisfy each schema. Lifecycle events + // (34 of 37) carry `outcome: 'success'`; 3 observation events stay + // outcome-less. Payloads are intentionally narrow — broader fixture + // coverage lives in privacy-fixture.test.ts. + const cases: Array<{event: AnalyticsEventName; properties?: Record}> = [ + {event: AnalyticsEventNames.ANALYTICS_DISABLED, properties: {}}, + {event: AnalyticsEventNames.AUTH_LOGIN, properties: {outcome: 'success'}}, + {event: AnalyticsEventNames.AUTH_LOGOUT, properties: {outcome: 'success'}}, + { + event: AnalyticsEventNames.BRV_INIT, + properties: {had_existing_brv_dir: false, outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.CONNECTOR_INSTALLED, + properties: {agent_target: 'claude-code', connector_id: 'rules', outcome: 'success'}, + }, + { + event: AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, + properties: { + file_relative_path_hash: validHashHex, + outcome: 'success', + project_path_hash: validHashHex, + }, + }, + {event: AnalyticsEventNames.DAEMON_RESET_EXECUTED, properties: {outcome: 'success', reset_scope: 'project'}}, + { + event: AnalyticsEventNames.HUB_PACKAGE_INSTALLED, + properties: {outcome: 'success', package_identifier: 'team/space'}, + }, + { + event: AnalyticsEventNames.HUB_REGISTRY_ADDED, + properties: {is_default: true, outcome: 'success', registry_kind: 'byterover'}, + }, + {event: AnalyticsEventNames.HUB_REGISTRY_REMOVED, properties: {outcome: 'success', registry_kind: 'byterover'}}, + { + event: AnalyticsEventNames.MIGRATE_RUN, + properties: {dry_run: false, mode: 'forward', outcome: 'success'}, + }, + {event: AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED, properties: {mode: 'auto', outcome: 'success'}}, + {event: AnalyticsEventNames.ONBOARDING_COMPLETED, properties: {outcome: 'success'}}, + { + event: AnalyticsEventNames.REVIEW_APPROVED, + properties: {operation_kind: 'add', outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.REVIEW_REJECTED, + properties: {operation_kind: 'add', outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.REVIEW_TOGGLED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.SETTING_CHANGED, + properties: {outcome: 'success', setting_key: 'agentPool.maxSize', value_kind: 'integer'}, + }, + { + event: AnalyticsEventNames.SETTING_RESET, + properties: {outcome: 'success', setting_key: 'agentPool.maxSize', value_kind: 'integer'}, + }, + { + event: AnalyticsEventNames.SOURCE_ADDED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + {event: AnalyticsEventNames.SOURCE_REMOVED, properties: {outcome: 'success', project_path_hash: validHashHex}}, + { + event: AnalyticsEventNames.SPACE_SWITCHED, + properties: {from_space_id: 'a', outcome: 'success'}, + }, + {event: AnalyticsEventNames.VC_BRANCHED, properties: {outcome: 'success', project_path_hash: validHashHex}}, + {event: AnalyticsEventNames.VC_CHECKED_OUT, properties: {outcome: 'success', project_path_hash: validHashHex}}, + { + event: AnalyticsEventNames.VC_CLONED, + properties: {outcome: 'success', remote_kind: 'byterover'}, + }, + { + event: AnalyticsEventNames.VC_COMMIT, + properties: {had_message: true, outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_DISCARDED, + properties: {discard_scope: 'file', outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_FETCHED, + properties: {outcome: 'success', project_path_hash: validHashHex, remote_kind: 'byterover'}, + }, + { + event: AnalyticsEventNames.VC_INIT, + properties: {had_existing_git_dir: false, outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_MERGED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_PULLED, + properties: { + branch_name_hash: validHashHex, + outcome: 'success', + project_path_hash: validHashHex, + remote_kind: 'byterover', + }, + }, + { + event: AnalyticsEventNames.VC_PUSHED, + properties: { + branch_name_hash: validHashHex, + outcome: 'success', + project_path_hash: validHashHex, + remote_kind: 'byterover', + }, + }, + { + event: AnalyticsEventNames.VC_REMOTE_CHANGED, + properties: { + change_kind: 'added', + outcome: 'success', + project_path_hash: validHashHex, + remote_kind: 'byterover', + }, + }, + { + event: AnalyticsEventNames.VC_RESET_EXECUTED, + properties: {outcome: 'success', project_path_hash: validHashHex, reset_mode: 'soft'}, + }, + { + event: AnalyticsEventNames.WEBUI_SESSION_ENDED, + properties: {session_duration_ms: 5000, started_at_unix_ms: 1_700_000_000_000}, + }, + {event: AnalyticsEventNames.WEBUI_SESSION_STARTED, properties: {started_at_unix_ms: 1_700_000_000_000}}, + { + event: AnalyticsEventNames.WORKTREE_ADDED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + {event: AnalyticsEventNames.WORKTREE_REMOVED, properties: {outcome: 'success', project_path_hash: validHashHex}}, + ] + + for (const {event, properties} of cases) { + it(`dispatches ${event} to analyticsClient.track`, async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event, properties}, 'client-1') + + const calls = analyticsClient.trackCalls.filter((c) => c.event === event) + expect(calls.length, `dispatch case missing or dropped for ${event}`).to.equal(1) + }) + } + + it('coverage matches schema count (37 new events covered)', () => { + expect(cases.length, 'must enumerate all 37 new event names').to.equal(37) + }) + }) +}) diff --git a/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts new file mode 100644 index 000000000..6c5c2b5a9 --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts @@ -0,0 +1,194 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {spy} from 'sinon' + +import type { + IJsonlAnalyticsStore, + JsonlAnalyticsStoreListOptions, + JsonlAnalyticsStoreListResult, +} from '../../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {StoredAnalyticsRecord} from '../../../../../../src/shared/analytics/stored-record.js' + +import {AnalyticsListHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-list-handler.js' +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +type AnalyticsListRequestHandler = ( + data: unknown, + clientId: string, +) => Promise<{rows: StoredAnalyticsRecord[]; total: number}> + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: `rec-${Math.random().toString(16).slice(2, 8)}`, + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +type FakeJsonlStore = IJsonlAnalyticsStore & { + listSpy: ReturnType +} + +function makeFakeJsonlStore(rows: StoredAnalyticsRecord[]): FakeJsonlStore { + const listImpl = async (opts: JsonlAnalyticsStoreListOptions): Promise => { + const filtered = rows.filter((row) => { + if (opts.eventName !== undefined && row.name !== opts.eventName) return false + if (opts.status !== undefined && row.status !== opts.status) return false + return true + }) + return {rows: filtered.slice(opts.offset, opts.offset + opts.limit), total: filtered.length} + } + + const listSpy = spy(listImpl) + return { + async append() {}, + async clear() {}, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + list: listSpy, + listSpy, + loadPending: async () => rows.filter((r) => r.status === 'pending'), + async updateStatus() {}, + } +} + +describe('AnalyticsListHandler (M11.2)', () => { + it('should register a handler for analytics:list on setup()', () => { + const transport = createMockTransportServer() + new AnalyticsListHandler({jsonlStore: makeFakeJsonlStore([]), transport}).setup() + + expect(transport._handlers.has(AnalyticsEvents.LIST)).to.equal(true) + }) + + it('should return {rows: [], total: 0} for a malformed payload (no throw)', async () => { + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore([makeRecord()]) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + for (const malformed of [null, undefined, {}, {limit: 'not-a-number'}, {limit: 10}, {offset: 0}]) { + // eslint-disable-next-line no-await-in-loop + const result = await handler(malformed, 'client-1') + expect(result).to.deep.equal({rows: [], total: 0}) + } + + expect(jsonlStore.listSpy.called, 'malformed payload must NOT reach the store').to.equal(false) + }) + + it('should forward offset/limit to jsonlStore.list and return its result', async () => { + const records = [ + makeRecord({id: 'r1', name: 'a'}), + makeRecord({id: 'r2', name: 'b'}), + makeRecord({id: 'r3', name: 'c'}), + ] + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore(records) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + const result = await handler({limit: 2, offset: 1}, 'client-1') + + expect(jsonlStore.listSpy.calledOnce).to.equal(true) + expect(jsonlStore.listSpy.firstCall.args[0]).to.deep.equal({limit: 2, offset: 1}) + expect(result.total).to.equal(3) + expect(result.rows.map((r) => r.id)).to.deep.equal(['r2', 'r3']) + }) + + it('should forward eventName + status filter combos correctly', async () => { + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore([]) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + await handler({eventName: 'cli_invocation', limit: 10, offset: 0, status: 'pending'}, 'client-1') + + expect(jsonlStore.listSpy.firstCall.args[0]).to.deep.equal({ + eventName: 'cli_invocation', + limit: 10, + offset: 0, + status: 'pending', + }) + }) + + it('should redact forbidden keys from row.properties before returning', async () => { + const records = [ + makeRecord({ + id: 'r1', + properties: {command_id: 'status', password: 'leak', token: 'jwt-xxx'}, + }), + ] + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore(records) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + const result = await handler({limit: 10, offset: 0}, 'client-1') + + expect(result.rows[0].properties).to.deep.equal({command_id: 'status'}) + // Source rows must NOT have been mutated. + expect(records[0].properties).to.have.property('password', 'leak') + }) + + it('should NOT redact identity (locked decision: identity block stays intact)', async () => { + const records = [ + makeRecord({ + id: 'r1', + identity: {device_id: validIdentity.device_id, email: 'alice@example.com', name: 'Alice', user_id: 'u-1'}, + properties: {command_id: 'status'}, + }), + ] + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore(records) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + const result = await handler({limit: 10, offset: 0}, 'client-1') + + expect(result.rows[0].identity).to.deep.equal({ + device_id: validIdentity.device_id, + email: 'alice@example.com', + name: 'Alice', + user_id: 'u-1', + }) + }) + + it('should return {rows: [], total: 0} when the store throws (no daemon crash)', async () => { + const transport = createMockTransportServer() + const throwingStore: IJsonlAnalyticsStore = { + async append() {}, + async clear() {}, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + async list() { + throw new Error('store boom') + }, + loadPending: async () => [], + async updateStatus() {}, + } + new AnalyticsListHandler({jsonlStore: throwingStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + let result: undefined | {rows: StoredAnalyticsRecord[]; total: number} + let threw = false + try { + result = await handler({limit: 10, offset: 0}, 'client-1') + } catch { + threw = true + } + + expect(threw, 'handler MUST NOT propagate store throws').to.equal(false) + expect(result).to.deep.equal({rows: [], total: 0}) + }) +}) diff --git a/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts new file mode 100644 index 000000000..f1aa88d21 --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts @@ -0,0 +1,203 @@ +import {expect} from 'chai' + +import type {IAnalyticsBackoffPolicy} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' +import { + AnalyticsStatusHandler, + consecutiveFailuresToReachabilityState, +} from '../../../../../../src/server/infra/transport/handlers/analytics-status-handler.js' +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +/** + * M4.6 tests for the analytics-status surface: + * - the pure `consecutiveFailuresToReachabilityState` mapper + * - the `AnalyticsStatusHandler` composition (runtime state + + * backoff state + endpoint + enabled flag) + * + * Test doubles below are hoisted above the top-level `describe` to + * satisfy `unicorn/consistent-function-scoping`. They cover only the + * surfaces the handler reads from each collaborator. + */ +type RuntimeStateSnapshot = { + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number +} + +function makeClientStub(state: RuntimeStateSnapshot): IAnalyticsClient { + return { + abort() {}, + flush: async () => AnalyticsBatch.create([]), + getRuntimeState: async () => state, + async onAuthTransition() {}, + track() {}, + } +} + +function makePolicyStub( + consecutiveFailures: number, + nextDelayMs: number, + isRateLimited = false, +): IAnalyticsBackoffPolicy { + return { + applyServerHint() {}, + consecutiveFailures: () => consecutiveFailures, + isRateLimited: () => isRateLimited, + nextDelayMs: () => nextDelayMs, + onFailure() {}, + onSuccess() {}, + } +} + +describe('M4.6 analytics status handler', () => { +describe('consecutiveFailuresToReachabilityState', () => { + it('returns "healthy" for zero failures', () => { + expect(consecutiveFailuresToReachabilityState(0)).to.equal('healthy') + }) + + it('returns "degraded" for one failure', () => { + expect(consecutiveFailuresToReachabilityState(1)).to.equal('degraded') + }) + + it('returns "degraded" for two failures', () => { + expect(consecutiveFailuresToReachabilityState(2)).to.equal('degraded') + }) + + it('returns "unreachable" at the 3-failure threshold', () => { + expect(consecutiveFailuresToReachabilityState(3)).to.equal('unreachable') + }) + + it('returns "unreachable" for many failures (counter is unbounded)', () => { + expect(consecutiveFailuresToReachabilityState(50)).to.equal('unreachable') + }) + + it('treats negative or NaN input defensively as "healthy" (no caller should pass these, but the mapper must not crash)', () => { + // Defense-in-depth: the policy never produces these values, but if a + // future change accidentally pipes a malformed counter through, the + // user should see the most-optimistic label rather than a runtime + // error in the status command's hot path. + expect(consecutiveFailuresToReachabilityState(-1)).to.equal('healthy') + expect(consecutiveFailuresToReachabilityState(Number.NaN)).to.equal('healthy') + }) +}) + +describe('AnalyticsStatusHandler', () => { + it('returns the composed wire response for an enabled, healthy daemon', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 4}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + + const fn = transport._handlers.get(AnalyticsEvents.STATUS) + if (!fn) throw new Error('STATUS handler not registered') + const response = await fn(undefined, 'client-1') + + expect(response).to.deep.equal({ + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: 1_700_000_000_000, + queueDepth: 4, + }) + }) + + it('renders "degraded" reachability when 1-2 consecutive failures', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(2, 120_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.backoff).to.deep.equal({consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}) + }) + + it('renders "unreachable" reachability when 3+ consecutive failures', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 5}), + backoffPolicy: makePolicyStub(5, 300_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.backoff.state).to.equal('unreachable') + expect(response.backoff.consecutiveFailures).to.equal(5) + }) + + it('forces backoff.state to "unreachable" when endpoint is missing (empty string)', async () => { + // BRV_ANALYTICS_BASE_URL not configured: ticket says endpoint shows + // "(not configured)" AND state is forced to "unreachable" regardless + // of consecutive failures. + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: '', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.endpoint).to.equal('(not configured)') + expect(response.backoff.state, 'override forces unreachable when endpoint missing').to.equal('unreachable') + }) + + it('keeps the JSON shape stable when analytics is disabled (CLI hides text fields; programmatic shape unchanged)', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 7, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 3}), + backoffPolicy: makePolicyStub(1, 60_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => false, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.enabled).to.equal(false) + // All other fields still populated — CLI decides whether to render them. + expect(response.queueDepth).to.equal(3) + expect(response.droppedCount).to.equal(7) + expect(response.lastFlushAt).to.equal(1_700_000_000_000) + expect(response.backoff).to.deep.equal({consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}) + }) + + it('omits lastFlushAt from the wire when the daemon has never shipped', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 2}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.lastFlushAt, 'undefined → key absent (CLI renders "never")').to.equal(undefined) + }) +}) +}) diff --git a/test/unit/server/utils/hash-path.test.ts b/test/unit/server/utils/hash-path.test.ts new file mode 100644 index 000000000..e3fca8cad --- /dev/null +++ b/test/unit/server/utils/hash-path.test.ts @@ -0,0 +1,33 @@ +import {expect} from 'chai' + +import {hashProjectPath} from '../../../../src/server/utils/hash-path.js' + +describe('hashProjectPath', () => { + it('returns a 64-character lowercase hex sha256 digest', () => { + const hash = hashProjectPath('/Users/test/project') + expect(hash).to.match(/^[0-9a-f]{64}$/) + }) + + it('is deterministic — same input yields same hash', () => { + const a = hashProjectPath('/Users/test/project') + const b = hashProjectPath('/Users/test/project') + expect(a).to.equal(b) + }) + + it('differs across different inputs', () => { + const a = hashProjectPath('/Users/test/project') + const b = hashProjectPath('/Users/test/other') + expect(a).to.not.equal(b) + }) + + it('hashes the empty string without throwing', () => { + const hash = hashProjectPath('') + expect(hash).to.match(/^[0-9a-f]{64}$/) + }) + + it('treats trailing slash as a distinct path (verbatim hash, no normalization)', () => { + const a = hashProjectPath('/Users/test/project') + const b = hashProjectPath('/Users/test/project/') + expect(a).to.not.equal(b) + }) +}) diff --git a/test/unit/shared/analytics/cli-metadata-schema.test.ts b/test/unit/shared/analytics/cli-metadata-schema.test.ts new file mode 100644 index 000000000..9c4536cb8 --- /dev/null +++ b/test/unit/shared/analytics/cli-metadata-schema.test.ts @@ -0,0 +1,97 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + CliMetadataSchema, + CliRequestBaseSchema, +} from '../../../../src/shared/analytics/cli-metadata-schema.js' + +const baseValid = { + client_sent_at: 1_700_000_000_000, + command_id: 'query', + flag_names: ['format'], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, +} + +describe('cli-metadata-schema', () => { + describe('CliMetadataSchema', () => { + describe('valid payloads', () => { + it('accepts the 8-field shape without terminal_program', () => { + expect(CliMetadataSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts terminal_program when set to a non-empty string', () => { + expect(CliMetadataSchema.safeParse({...baseValid, terminal_program: 'iTerm.app'}).success).to.equal(true) + }) + + it('accepts empty flag_names array', () => { + expect(CliMetadataSchema.safeParse({...baseValid, flag_names: []}).success).to.equal(true) + }) + + it('accepts each package_manager enum value', () => { + for (const pm of ['npm', 'yarn', 'pnpm', 'bun', 'unknown'] as const) { + expect(CliMetadataSchema.safeParse({...baseValid, package_manager: pm}).success).to.equal(true) + } + }) + + it('accepts runtime "bun" and "node"', () => { + for (const runtime of ['node', 'bun'] as const) { + expect(CliMetadataSchema.safeParse({...baseValid, runtime}).success).to.equal(true) + } + }) + + it('accepts client_sent_at = 0 (nonnegative integer)', () => { + expect(CliMetadataSchema.safeParse({...baseValid, client_sent_at: 0}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {command_id: _omit, ...withoutCommandId} = baseValid + expect(CliMetadataSchema.safeParse(withoutCommandId).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {client_sent_at: _omit2, ...withoutTs} = baseValid + expect(CliMetadataSchema.safeParse(withoutTs).success).to.equal(false) + }) + + it('rejects out-of-enum runtime / package_manager', () => { + expect(CliMetadataSchema.safeParse({...baseValid, runtime: 'deno'}).success).to.equal(false) + expect(CliMetadataSchema.safeParse({...baseValid, package_manager: 'brew'}).success).to.equal(false) + }) + + it('rejects empty command_id and empty terminal_program', () => { + expect(CliMetadataSchema.safeParse({...baseValid, command_id: ''}).success).to.equal(false) + expect(CliMetadataSchema.safeParse({...baseValid, terminal_program: ''}).success).to.equal(false) + }) + + it('rejects negative or non-integer client_sent_at', () => { + expect(CliMetadataSchema.safeParse({...baseValid, client_sent_at: -1}).success).to.equal(false) + expect(CliMetadataSchema.safeParse({...baseValid, client_sent_at: 1.5}).success).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect(CliMetadataSchema.safeParse({...baseValid, sneaky: 'leak'}).success).to.equal(false) + }) + }) +}) + + describe('CliRequestBaseSchema', () => { + it('accepts the empty payload (cli_metadata is optional)', () => { + expect(CliRequestBaseSchema.safeParse({}).success).to.equal(true) + }) + + it('accepts a valid cli_metadata block', () => { + expect(CliRequestBaseSchema.safeParse({cli_metadata: baseValid}).success).to.equal(true) + }) + + it('rejects a malformed cli_metadata block (inner strict-mode bubbles up)', () => { + expect( + CliRequestBaseSchema.safeParse({cli_metadata: {...baseValid, runtime: 'deno'}}).success, + ).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/emit.test.ts b/test/unit/shared/analytics/emit.test.ts new file mode 100644 index 000000000..e671a3553 --- /dev/null +++ b/test/unit/shared/analytics/emit.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable camelcase */ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {CliInvocationProps} from '../../../../src/shared/analytics/events/cli-invocation.js' + +import {emitAnalytics} from '../../../../src/shared/analytics/emit.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' +import {AnalyticsEvents} from '../../../../src/shared/transport/events/analytics-events.js' + +function makeStubClient(overrides: Partial = {}): ITransportClient { + return { + connect: stub(), + disconnect: stub(), + getClientId: stub(), + getState: stub(), + isConnected: stub().resolves(true), + joinRoom: stub(), + leaveRoom: stub(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub(), + requestWithAck: stub(), + ...overrides, + } as unknown as ITransportClient +} + +const fullCliInvocation: CliInvocationProps = { + client_sent_at: 1_700_000_000_000, + command_id: 'status', + flag_names: [], + is_ci: false, + is_tty: true, + package_manager: 'npm', + runtime: 'node', +} + +describe('emitAnalytics', () => { + it('should call client.request with analytics:track and the expected payload (typed event + props)', () => { + const client = makeStubClient() + + emitAnalytics(client, AnalyticsEventNames.CLI_INVOCATION, fullCliInvocation) + + const requestStub = client.request as ReturnType + expect(requestStub.calledOnce).to.equal(true) + expect(requestStub.firstCall.args[0]).to.equal(AnalyticsEvents.TRACK) + expect(requestStub.firstCall.args[1]).to.deep.equal({ + event: AnalyticsEventNames.CLI_INVOCATION, + properties: fullCliInvocation, + }) + }) + + it('should accept the daemon_start event with no properties argument', () => { + const client = makeStubClient() + + // daemon_start has empty `{}` schema; the typed PropsArg makes properties + // optional in this case so callers do not have to pass `{}` explicitly. + emitAnalytics(client, AnalyticsEventNames.DAEMON_START) + + const requestStub = client.request as ReturnType + expect(requestStub.calledOnce).to.equal(true) + expect(requestStub.firstCall.args[1]).to.deep.equal({ + event: AnalyticsEventNames.DAEMON_START, + properties: undefined, + }) + }) + + it('should NOT throw when client.request throws (e.g. TransportNotConnectedError)', () => { + const client = makeStubClient({ + request: stub().throws(new Error('not connected')) as unknown as ITransportClient['request'], + }) + + expect(() => emitAnalytics(client, AnalyticsEventNames.DAEMON_START)).to.not.throw() + }) + + it('should emit exactly ONE event per call', () => { + const client = makeStubClient() + + emitAnalytics(client, AnalyticsEventNames.DAEMON_START) + emitAnalytics(client, AnalyticsEventNames.CLI_INVOCATION, fullCliInvocation) + + const requestStub = client.request as ReturnType + expect(requestStub.callCount).to.equal(2) + }) +}) diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts new file mode 100644 index 000000000..29232dbef --- /dev/null +++ b/test/unit/shared/analytics/event-names.test.ts @@ -0,0 +1,80 @@ +import {expect} from 'chai' + +import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' + +describe('AnalyticsEventNames', () => { + it('should expose exactly the fifty-one shipped event names', () => { + expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ + 'ANALYTICS_DISABLED', + 'AUTH_LOGIN', + 'AUTH_LOGOUT', + 'BRV_INIT', + 'CLI_INVOCATION', + 'CONNECTOR_INSTALLED', + 'CONTENT_MIGRATED', + 'CONTEXT_TREE_FILE_EDITED', + 'CURATE_OPERATION_APPLIED', + 'CURATE_RUN_COMPLETED', + 'DAEMON_RESET_EXECUTED', + 'DAEMON_START', + 'HUB_PACKAGE_INSTALLED', + 'HUB_REGISTRY_ADDED', + 'HUB_REGISTRY_REMOVED', + 'MCP_SESSION_ENDED', + 'MCP_SESSION_START', + 'MCP_TOOL_CALLED', + 'MIGRATE_RUN', + 'ONBOARDING_AUTO_SETUP_STARTED', + 'ONBOARDING_COMPLETED', + 'QUERY_COMPLETED', + 'REVIEW_APPROVED', + 'REVIEW_REJECTED', + 'REVIEW_TOGGLED', + 'SETTING_CHANGED', + 'SETTING_RESET', + 'SOURCE_ADDED', + 'SOURCE_REMOVED', + 'SPACE_SWITCHED', + 'SWARM_ONBOARDED', + 'SWARM_QUERY_COMPLETED', + 'SWARM_STORE_COMPLETED', + 'TASK_COMPLETED', + 'TASK_CREATED', + 'TASK_FAILED', + 'VC_BRANCHED', + 'VC_CHECKED_OUT', + 'VC_CLONED', + 'VC_COMMIT', + 'VC_DISCARDED', + 'VC_FETCHED', + 'VC_INIT', + 'VC_MERGED', + 'VC_PULLED', + 'VC_PUSHED', + 'VC_REMOTE_CHANGED', + 'VC_RESET_EXECUTED', + 'WEBUI_SESSION_ENDED', + 'WEBUI_SESSION_STARTED', + 'WORKTREE_ADDED', + 'WORKTREE_REMOVED', + ]) + }) + + it('should map each key to a snake_case wire string', () => { + expect(AnalyticsEventNames.DAEMON_START).to.equal('daemon_start') + expect(AnalyticsEventNames.CLI_INVOCATION).to.equal('cli_invocation') + expect(AnalyticsEventNames.CURATE_OPERATION_APPLIED).to.equal('curate_operation_applied') + expect(AnalyticsEventNames.CURATE_RUN_COMPLETED).to.equal('curate_run_completed') + expect(AnalyticsEventNames.MCP_SESSION_START).to.equal('mcp_session_start') + expect(AnalyticsEventNames.MCP_TOOL_CALLED).to.equal('mcp_tool_called') + expect(AnalyticsEventNames.QUERY_COMPLETED).to.equal('query_completed') + expect(AnalyticsEventNames.TASK_CREATED).to.equal('task_created') + expect(AnalyticsEventNames.TASK_COMPLETED).to.equal('task_completed') + expect(AnalyticsEventNames.TASK_FAILED).to.equal('task_failed') + }) + + it('should expose AnalyticsEventName as the union of values', () => { + const sample: AnalyticsEventName = AnalyticsEventNames.DAEMON_START + expect(sample).to.equal('daemon_start') + }) +}) diff --git a/test/unit/shared/analytics/events/cli-invocation.test.ts b/test/unit/shared/analytics/events/cli-invocation.test.ts new file mode 100644 index 000000000..eca37b145 --- /dev/null +++ b/test/unit/shared/analytics/events/cli-invocation.test.ts @@ -0,0 +1,84 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CliInvocationSchema} from '../../../../../src/shared/analytics/events/cli-invocation.js' + +const baseValid = { + client_sent_at: 1_700_000_000_000, + command_id: 'vc:add', + flag_names: ['--detach'], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, +} + +describe('CliInvocationSchema', () => { + describe('valid payloads', () => { + it('should accept all required fields without terminal_program', () => { + expect(CliInvocationSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept terminal_program as a non-empty string', () => { + expect(CliInvocationSchema.safeParse({...baseValid, terminal_program: 'iTerm.app'}).success).to.equal(true) + }) + + it('should accept empty flag_names array', () => { + expect(CliInvocationSchema.safeParse({...baseValid, flag_names: []}).success).to.equal(true) + }) + + it('should accept runtime "bun"', () => { + expect(CliInvocationSchema.safeParse({...baseValid, runtime: 'bun'}).success).to.equal(true) + }) + + it('should accept all package_manager values', () => { + for (const pm of ['npm', 'yarn', 'pnpm', 'bun', 'unknown']) { + expect(CliInvocationSchema.safeParse({...baseValid, package_manager: pm}).success).to.equal(true) + } + }) + }) + + describe('invalid payloads', () => { + it('should reject empty command_id', () => { + expect(CliInvocationSchema.safeParse({...baseValid, command_id: ''}).success).to.equal(false) + }) + + it('should reject non-string command_id', () => { + expect(CliInvocationSchema.safeParse({...baseValid, command_id: 42}).success).to.equal(false) + }) + + it('should reject non-array flag_names', () => { + expect(CliInvocationSchema.safeParse({...baseValid, flag_names: 'oops'}).success).to.equal(false) + }) + + it('should reject non-boolean is_tty', () => { + expect(CliInvocationSchema.safeParse({...baseValid, is_tty: 'yes'}).success).to.equal(false) + }) + + it('should reject non-boolean is_ci', () => { + expect(CliInvocationSchema.safeParse({...baseValid, is_ci: 'no'}).success).to.equal(false) + }) + + it('should reject unknown runtime values', () => { + expect(CliInvocationSchema.safeParse({...baseValid, runtime: 'deno'}).success).to.equal(false) + }) + + it('should reject unknown package_manager values', () => { + expect(CliInvocationSchema.safeParse({...baseValid, package_manager: 'homebrew'}).success).to.equal(false) + }) + + it('should reject empty terminal_program when present', () => { + expect(CliInvocationSchema.safeParse({...baseValid, terminal_program: ''}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(CliInvocationSchema.safeParse({...baseValid, sneaky: 'leak'}).success).to.equal(false) + }) + + it('should reject missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {command_id: _, ...withoutCommandId} = baseValid + expect(CliInvocationSchema.safeParse(withoutCommandId).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/curate-operation-applied.test.ts b/test/unit/shared/analytics/events/curate-operation-applied.test.ts new file mode 100644 index 000000000..9d2915ef1 --- /dev/null +++ b/test/unit/shared/analytics/events/curate-operation-applied.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CurateOperationAppliedSchema} from '../../../../../src/shared/analytics/events/curate-operation-applied.js' + +const baseValid = { + keywords: [], + knowledge_path: 'notes/test', + needs_review: false, + operation_type: 'ADD' as const, + relative_path: '.brv/context-tree/notes/test.md', + tags: [], + task_id: 'task-uuid-123', +} + +describe('CurateOperationAppliedSchema', () => { + describe('valid payloads', () => { + it('accepts the minimal required payload', () => { + expect(CurateOperationAppliedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts each operation_type enum value', () => { + for (const operation_type of ['ADD', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT'] as const) { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, operation_type}).success).to.equal(true) + } + }) + + it('accepts optional impact and confidence enum values', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, confidence: 'high', impact: 'low'}).success).to.equal( + true, + ) + }) + + it('accepts needs_review=true', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, needs_review: true}).success).to.equal(true) + }) + + it('accepts payloads with keywords / tags as required arrays (default empty) and optional related', () => { + // keywords/tags are required after the M14 review tightening; the + // base payload already carries them as []. + expect(CurateOperationAppliedSchema.safeParse({...baseValid}).success).to.equal(true) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: ['a']}).success).to.equal(true) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: ['k']}).success).to.equal(true) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, related: ['r']}).success).to.equal(true) + expect( + CurateOperationAppliedSchema.safeParse({...baseValid, keywords: ['k'], related: ['r'], tags: ['t']}).success, + ).to.equal(true) + }) + + it('rejects payloads missing the required keywords / tags arrays', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {keywords: _k, ...withoutKeywords} = baseValid + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {tags: _t, ...withoutTags} = baseValid + expect(CurateOperationAppliedSchema.safeParse(withoutKeywords).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse(withoutTags).success).to.equal(false) + }) + + it('accepts tags / keywords / related with exactly 50 entries each', () => { + const fifty = Array.from({length: 50}, (_, i) => `entry-${i}`) + expect( + CurateOperationAppliedSchema.safeParse({...baseValid, keywords: fifty, related: fifty, tags: fifty}).success, + ).to.equal(true) + }) + + it('accepts tags / keywords / related entries up to 256 chars each', () => { + const at256 = 'x'.repeat(256) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: [at256]}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {operation_type: _omit, ...withoutOpType} = baseValid + expect(CurateOperationAppliedSchema.safeParse(withoutOpType).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _omit2, ...withoutTaskId} = baseValid + expect(CurateOperationAppliedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + it('rejects out-of-enum operation_type', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, operation_type: 'RENAME'}).success).to.equal(false) + }) + + it('rejects out-of-enum impact and confidence', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, impact: 'medium'}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, confidence: 'maybe'}).success).to.equal(false) + }) + + it('rejects empty relative_path / knowledge_path / task_id', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, relative_path: ''}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, knowledge_path: ''}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('rejects tags / keywords / related with more than 50 entries', () => { + const fiftyOne = Array.from({length: 51}, (_, i) => `entry-${i}`) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: fiftyOne}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: fiftyOne}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, related: fiftyOne}).success).to.equal(false) + }) + + it('rejects tags / keywords / related entries longer than 256 chars', () => { + const at257 = 'x'.repeat(257) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: [at257]}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: [at257]}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, related: [at257]}).success).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/curate-run-completed.test.ts b/test/unit/shared/analytics/events/curate-run-completed.test.ts new file mode 100644 index 000000000..63ebc7cfa --- /dev/null +++ b/test/unit/shared/analytics/events/curate-run-completed.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CurateRunCompletedSchema} from '../../../../../src/shared/analytics/events/curate-run-completed.js' + +const baseValid = { + duration_ms: 5000, + operations_added: 1, + operations_deleted: 0, + operations_failed: 0, + operations_merged: 0, + operations_updated: 2, + outcome: 'completed' as const, + pending_review_count: 0, + task_id: 'task-uuid-123', + task_type: 'curate' as const, +} + +describe('CurateRunCompletedSchema', () => { + describe('valid payloads', () => { + it('accepts the minimal required payload', () => { + expect(CurateRunCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts each task_type enum value', () => { + for (const task_type of ['curate', 'curate-folder'] as const) { + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type}).success).to.equal(true) + } + }) + + it('accepts each outcome enum value', () => { + for (const outcome of ['completed', 'partial', 'cancelled', 'error'] as const) { + expect(CurateRunCompletedSchema.safeParse({...baseValid, outcome}).success).to.equal(true) + } + }) + + it('accepts zero counts and duration_ms=0', () => { + const zeroed = { + ...baseValid, + duration_ms: 0, + operations_added: 0, + operations_deleted: 0, + operations_failed: 0, + operations_merged: 0, + operations_updated: 0, + pending_review_count: 0, + } + expect(CurateRunCompletedSchema.safeParse(zeroed).success).to.equal(true) + }) + + it('accepts a populated space_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid-abc'}).success).to.equal(true) + }) + + it('accepts a populated team_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, team_id: 'team-uuid-abc'}).success).to.equal(true) + }) + + it('accepts both space_id and team_id together', () => { + expect( + CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid', team_id: 'team-uuid'}).success, + ).to.equal(true) + }) + + it('accepts payloads with no space_id and no team_id (standalone project)', () => { + expect(CurateRunCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {outcome: _o, ...withoutOutcome} = baseValid + expect(CurateRunCompletedSchema.safeParse(withoutOutcome).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _t, ...withoutTaskId} = baseValid + expect(CurateRunCompletedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + it('rejects out-of-enum outcome', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, outcome: 'mystery'}).success).to.equal(false) + }) + + it('rejects an unknown task_type but accepts every canonical TASK_TYPE_VALUES entry', () => { + // M14.2 widened task_type from ['curate', 'curate-folder'] to the + // canonical TASK_TYPE_VALUES tuple so curate-tool-mode round-trips + // the wire boundary. Genuinely unknown values still reject. + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'not-a-real-type'}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'curate-tool-mode'}).success).to.equal(true) + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'query'}).success).to.equal(true) + }) + + it('rejects negative counts and duration_ms', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, operations_added: -1}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, pending_review_count: -1}).success).to.equal(false) + }) + + it('rejects non-integer counts', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, operations_added: 1.5}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, duration_ms: 1.5}).success).to.equal(false) + }) + + it('rejects empty task_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) + }) + + it('rejects empty / over-cap space_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: ''}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'x'.repeat(65)}).success).to.equal(false) + }) + + it('rejects empty / over-cap team_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, team_id: ''}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, team_id: 'x'.repeat(65)}).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/daemon-start.test.ts b/test/unit/shared/analytics/events/daemon-start.test.ts new file mode 100644 index 000000000..1c00e158b --- /dev/null +++ b/test/unit/shared/analytics/events/daemon-start.test.ts @@ -0,0 +1,27 @@ + +import {expect} from 'chai' + +import {DaemonStartSchema} from '../../../../../src/shared/analytics/events/daemon-start.js' + +describe('DaemonStartSchema', () => { + it('should accept an empty object', () => { + const result = DaemonStartSchema.safeParse({}) + expect(result.success).to.equal(true) + }) + + it('should reject unknown fields (strict)', () => { + const result = DaemonStartSchema.safeParse({extra: 'nope'}) + expect(result.success).to.equal(false) + }) + + it('should reject null', () => { + const result = DaemonStartSchema.safeParse(null) + expect(result.success).to.equal(false) + }) + + it('should reject non-object payloads', () => { + expect(DaemonStartSchema.safeParse('hi').success).to.equal(false) + expect(DaemonStartSchema.safeParse(42).success).to.equal(false) + expect(DaemonStartSchema.safeParse([]).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/events/mcp-session-start.test.ts b/test/unit/shared/analytics/events/mcp-session-start.test.ts new file mode 100644 index 000000000..87d13705f --- /dev/null +++ b/test/unit/shared/analytics/events/mcp-session-start.test.ts @@ -0,0 +1,30 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {McpSessionStartSchema} from '../../../../../src/shared/analytics/events/mcp-session-start.js' + +describe('McpSessionStartSchema', () => { + it('should accept a valid client_name', () => { + expect(McpSessionStartSchema.safeParse({client_name: 'Cursor'}).success).to.equal(true) + }) + + it('should reject empty client_name', () => { + expect(McpSessionStartSchema.safeParse({client_name: ''}).success).to.equal(false) + }) + + it('should reject missing client_name', () => { + expect(McpSessionStartSchema.safeParse({}).success).to.equal(false) + }) + + it('should reject non-string client_name', () => { + expect(McpSessionStartSchema.safeParse({client_name: 42}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(McpSessionStartSchema.safeParse({client_name: 'Cursor', client_version: '1.0.0'}).success).to.equal(false) + }) + + it('should reject null', () => { + expect(McpSessionStartSchema.safeParse(null).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/events/mcp-tool-called.test.ts b/test/unit/shared/analytics/events/mcp-tool-called.test.ts new file mode 100644 index 000000000..978f6cba2 --- /dev/null +++ b/test/unit/shared/analytics/events/mcp-tool-called.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {McpToolCalledSchema} from '../../../../../src/shared/analytics/events/mcp-tool-called.js' + +const baseValid = { + client_name: 'Cursor', + duration_ms: 123, + success: true, + tool_name: 'brv-query' as const, +} + +describe('McpToolCalledSchema', () => { + describe('valid payloads', () => { + it('should accept tool_name="brv-query"', () => { + expect(McpToolCalledSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept tool_name="brv-curate"', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, tool_name: 'brv-curate'}).success).to.equal(true) + }) + + it('should accept success=false', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, success: false}).success).to.equal(true) + }) + + it('should accept duration_ms=0', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, duration_ms: 0}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('should reject unknown tool_name', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, tool_name: 'mystery-tool'}).success).to.equal(false) + }) + + it('should reject empty client_name', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, client_name: ''}).success).to.equal(false) + }) + + it('should reject negative duration_ms', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + }) + + it('should reject non-integer duration_ms', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, duration_ms: 1.5}).success).to.equal(false) + }) + + it('should reject non-boolean success', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, success: 1}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, error_class: 'TimeoutError'}).success).to.equal(false) + }) + + it('should reject missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {success: _, ...withoutSuccess} = baseValid + expect(McpToolCalledSchema.safeParse(withoutSuccess).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/migrate-run.test.ts b/test/unit/shared/analytics/events/migrate-run.test.ts new file mode 100644 index 000000000..54f759e43 --- /dev/null +++ b/test/unit/shared/analytics/events/migrate-run.test.ts @@ -0,0 +1,195 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {MigrateRunSchema} from '../../../../../src/shared/analytics/events/migrate-run.js' + +const baseForwardSuccess = { + archived: 1, + dry_run: true, + failed: 0, + migrated: 2, + mode: 'forward' as const, + outcome: 'success' as const, + skipped: 3, +} + +const baseRollbackSuccess = { + deleted_html: 2, + dry_run: false, + mode: 'rollback' as const, + outcome: 'success' as const, + preserved_html: 1, + restored: 5, +} + +describe('MigrateRunSchema', () => { + describe('valid payloads', () => { + it('accepts a forward success payload with all counts', () => { + expect(MigrateRunSchema.safeParse(baseForwardSuccess).success).to.equal(true) + }) + + it('accepts a rollback success payload with all counts', () => { + expect(MigrateRunSchema.safeParse(baseRollbackSuccess).success).to.equal(true) + }) + + it('accepts a forward failure payload with failure_kind and no counts', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: false, + failure_kind: 'archive_exists', + mode: 'forward', + outcome: 'failure', + }) + expect(result.success).to.equal(true) + }) + + it('accepts a rollback failure payload with failure_kind', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: true, + failure_kind: 'no_archive', + mode: 'rollback', + outcome: 'failure', + }) + expect(result.success).to.equal(true) + }) + + it('accepts a forward payload with all counts zeroed', () => { + const result = MigrateRunSchema.safeParse({ + archived: 0, + dry_run: false, + failed: 0, + migrated: 0, + mode: 'forward', + outcome: 'success', + skipped: 0, + }) + expect(result.success).to.equal(true) + }) + + it('accepts a rollback payload with all counts zeroed', () => { + const result = MigrateRunSchema.safeParse({ + deleted_html: 0, + dry_run: false, + mode: 'rollback', + outcome: 'success', + preserved_html: 0, + restored: 0, + }) + expect(result.success).to.equal(true) + }) + + it('accepts a forward payload with only required fields', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: false, + mode: 'forward', + outcome: 'success', + }) + expect(result.success).to.equal(true) + }) + + it('accepts a rollback payload with only required fields', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: false, + mode: 'rollback', + outcome: 'success', + }) + expect(result.success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {mode: _mode, ...withoutMode} = baseForwardSuccess + expect(MigrateRunSchema.safeParse(withoutMode).success).to.equal(false) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {outcome: _outcome, ...withoutOutcome} = baseForwardSuccess + expect(MigrateRunSchema.safeParse(withoutOutcome).success).to.equal(false) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {dry_run: _dryRun, ...withoutDryRun} = baseForwardSuccess + expect(MigrateRunSchema.safeParse(withoutDryRun).success).to.equal(false) + }) + + it('rejects out-of-enum mode', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, mode: 'sideways'}).success, + ).to.equal(false) + }) + + it('rejects out-of-enum outcome', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, outcome: 'unknown'}).success, + ).to.equal(false) + }) + + it('rejects non-boolean dry_run', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, dry_run: 'yes'}).success, + ).to.equal(false) + }) + + it('rejects negative counts', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, migrated: -1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, restored: -1}).success, + ).to.equal(false) + }) + + it('rejects non-integer counts', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, archived: 1.5}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, deleted_html: 0.5}).success, + ).to.equal(false) + }) + + it('rejects empty failure_kind', () => { + expect( + MigrateRunSchema.safeParse({ + dry_run: false, + failure_kind: '', + mode: 'forward', + outcome: 'failure', + }).success, + ).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, mystery_field: 'oops'}).success, + ).to.equal(false) + }) + + it('rejects forward payload carrying rollback-only counters', () => { + // Discriminated-union guarantee: per-mode counter fields stay segregated. + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, deleted_html: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, preserved_html: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, restored: 1}).success, + ).to.equal(false) + }) + + it('rejects rollback payload carrying forward-only counters', () => { + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, migrated: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, archived: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, skipped: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, failed: 1}).success, + ).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/query-completed.test.ts b/test/unit/shared/analytics/events/query-completed.test.ts new file mode 100644 index 000000000..37a4c42e6 --- /dev/null +++ b/test/unit/shared/analytics/events/query-completed.test.ts @@ -0,0 +1,218 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {QueryCompletedSchema} from '../../../../../src/shared/analytics/events/query-completed.js' + +const baseValid = { + cache_hit: false, + duration_ms: 1234, + matched_doc_count: 5, + outcome: 'completed' as const, + read_doc_count: 2, + read_paths_with_metadata: [], + read_tool_call_count: 3, + search_call_count: 1, + task_id: 'task-uuid-456', + task_type: 'query' as const, +} + +const baseEntry = { + keywords: [], + related_paths: [], + relative_path: '.brv/notes/a.md', + tags: [], +} + +describe('QueryCompletedSchema', () => { + describe('valid payloads', () => { + it('accepts the minimal required payload with empty read_paths_with_metadata', () => { + expect(QueryCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts payloads omitting read_paths_with_metadata (optional outer array)', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {read_paths_with_metadata: _r, ...withoutReadPaths} = baseValid + expect(QueryCompletedSchema.safeParse(withoutReadPaths).success).to.equal(true) + }) + + it('accepts each outcome enum value', () => { + for (const outcome of ['completed', 'cancelled', 'error'] as const) { + expect(QueryCompletedSchema.safeParse({...baseValid, outcome}).success).to.equal(true) + } + }) + + it('accepts each tier literal value (0..4)', () => { + for (const tier of [0, 1, 2, 3, 4] as const) { + expect(QueryCompletedSchema.safeParse({...baseValid, tier}).success).to.equal(true) + } + }) + + it('accepts payloads omitting tier', () => { + expect(QueryCompletedSchema.safeParse({...baseValid}).success).to.equal(true) + }) + + it('accepts cache_hit=true', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, cache_hit: true}).success).to.equal(true) + }) + + it('accepts read_paths_with_metadata entries with empty metadata arrays', () => { + const entries = [ + {...baseEntry, relative_path: '.brv/a.md'}, + {...baseEntry, relative_path: '.brv/b.md'}, + ] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts entries with populated keywords, tags, and structured related_paths', () => { + const entries = [ + { + keywords: ['k1'], + related_paths: [{keywords: [], relative_path: 'r1', tags: []}], + relative_path: '.brv/a.md', + tags: ['t1'], + }, + ] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts read_paths_with_metadata with exactly 10 entries', () => { + const entries = Array.from({length: 10}, (_, i) => ({...baseEntry, relative_path: `.brv/file-${i}.md`})) + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts entries with keywords / tags at the 50-entry cap and 256-char strings', () => { + const fifty = Array.from({length: 50}, (_, i) => `entry-${i}`) + const at256 = 'x'.repeat(256) + const entries = [ + { + keywords: fifty, + related_paths: [{keywords: [], relative_path: at256, tags: []}], + relative_path: '.brv/a.md', + tags: fifty, + }, + ] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts a populated space_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid-abc'}).success).to.equal(true) + }) + + it('accepts a populated team_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, team_id: 'team-uuid-abc'}).success).to.equal(true) + }) + + it('accepts both space_id and team_id together', () => { + expect( + QueryCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid', team_id: 'team-uuid'}).success, + ).to.equal(true) + }) + + it('accepts payloads omitting space_id / team_id (standalone project)', () => { + expect(QueryCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts related_paths with up to 50 structured entries', () => { + const fifty = Array.from({length: 50}, (_, i) => ({ + keywords: [], + relative_path: `notes/related-${i}`, + tags: [], + })) + const entries = [{...baseEntry, related_paths: fifty}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {outcome: _o, ...withoutOutcome} = baseValid + expect(QueryCompletedSchema.safeParse(withoutOutcome).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _t, ...withoutTaskId} = baseValid + expect(QueryCompletedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + it('rejects out-of-enum outcome', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, outcome: 'partial'}).success).to.equal(false) + }) + + it('rejects tier outside 0..4', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, tier: 5}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, tier: -1}).success).to.equal(false) + }) + + it('rejects an unknown task_type but accepts every canonical TASK_TYPE_VALUES entry', () => { + // M14.2 widened task_type from z.literal('query') to the canonical + // TASK_TYPE_VALUES tuple so query-tool-mode round-trips the wire + // boundary. Genuinely unknown values still reject. + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'not-a-real-type'}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'query-tool-mode'}).success).to.equal(true) + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'curate'}).success).to.equal(true) + }) + + it('rejects negative or non-integer counts', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, matched_doc_count: -1}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, read_tool_call_count: 1.5}).success).to.equal(false) + }) + + it('rejects read_paths_with_metadata with more than 10 entries', () => { + const entries = Array.from({length: 11}, (_, i) => ({...baseEntry, relative_path: `.brv/file-${i}.md`})) + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects entries with empty relative_path', () => { + const entries = [{...baseEntry, relative_path: ''}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects entries missing required keywords / tags arrays', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {keywords: _k, ...withoutKeywords} = baseEntry + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {tags: _t, ...withoutTags} = baseEntry + expect( + QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: [withoutKeywords]}).success, + ).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: [withoutTags]}).success).to.equal( + false, + ) + }) + + it('rejects entries with more than 50 tags / keywords', () => { + const fiftyOne = Array.from({length: 51}, (_, i) => `entry-${i}`) + const tagsEntry = [{...baseEntry, tags: fiftyOne}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: tagsEntry}).success).to.equal(false) + }) + + it('rejects entries with tag / keyword string longer than 256 chars', () => { + const at257 = 'x'.repeat(257) + const entries = [{...baseEntry, keywords: [at257]}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects related_paths entries missing keywords / tags / relative_path', () => { + const entries = [{...baseEntry, related_paths: [{relative_path: 'r1'}]}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects unknown extra fields at top level (strict)', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) + }) + + it('rejects empty / over-cap space_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, space_id: ''}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, space_id: 'x'.repeat(65)}).success).to.equal(false) + }) + + it('rejects empty / over-cap team_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, team_id: ''}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, team_id: 'x'.repeat(65)}).success).to.equal(false) + }) + + it('rejects unknown extra fields inside an entry (strict)', () => { + const entries = [{...baseEntry, mystery: 'oops'}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/task-completed.test.ts b/test/unit/shared/analytics/events/task-completed.test.ts new file mode 100644 index 000000000..0a26154a1 --- /dev/null +++ b/test/unit/shared/analytics/events/task-completed.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {TaskCompletedSchema} from '../../../../../src/shared/analytics/events/task-completed.js' + +const baseValid = { + duration_ms: 250, + task_id: '550e8400-e29b-41d4-a716-446655440000', + task_type: 'query' as const, +} + +describe('TaskCompletedSchema', () => { + it('should accept a valid payload', () => { + expect(TaskCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept duration_ms=0', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, duration_ms: 0}).success).to.equal(true) + }) + + it('should reject negative duration_ms', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + }) + + it('should reject non-integer duration_ms', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, duration_ms: 1.5}).success).to.equal(false) + }) + + it('should reject unknown task_type', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, task_type: 'mystery'}).success).to.equal(false) + }) + + it('should reject empty task_id', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, result: 'leaked output'}).success).to.equal(false) + }) + + it('should reject missing required field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {duration_ms: _, ...withoutDuration} = baseValid + expect(TaskCompletedSchema.safeParse(withoutDuration).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/events/task-created.test.ts b/test/unit/shared/analytics/events/task-created.test.ts new file mode 100644 index 000000000..88d31d461 --- /dev/null +++ b/test/unit/shared/analytics/events/task-created.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {TaskCreatedSchema} from '../../../../../src/shared/analytics/events/task-created.js' + +const baseValid = { + has_files: false, + has_folder: false, + task_id: '550e8400-e29b-41d4-a716-446655440000', + task_type: 'curate' as const, +} + +describe('TaskCreatedSchema', () => { + describe('valid payloads', () => { + it('should accept all task_type values', () => { + for (const t of ['curate', 'curate-folder', 'query', 'search', 'dream']) { + expect(TaskCreatedSchema.safeParse({...baseValid, task_type: t}).success).to.equal(true) + } + }) + + it('should accept has_files=true and has_folder=true', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, has_files: true, has_folder: true}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('should reject unknown task_type', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, task_type: 'mystery'}).success).to.equal(false) + }) + + it('should reject empty task_id', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('should reject non-boolean has_files', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, has_files: 'yes'}).success).to.equal(false) + }) + + it('should reject non-boolean has_folder', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, has_folder: 1}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, file_path: '/leaked'}).success).to.equal(false) + }) + + it('should reject missing required field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _, ...withoutTaskId} = baseValid + expect(TaskCreatedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/task-failed.test.ts b/test/unit/shared/analytics/events/task-failed.test.ts new file mode 100644 index 000000000..930bc8d69 --- /dev/null +++ b/test/unit/shared/analytics/events/task-failed.test.ts @@ -0,0 +1,65 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {FailureKindValues, TaskFailedSchema} from '../../../../../src/shared/analytics/events/task-failed.js' + +const baseValid = { + duration_ms: 9000, + failure_kind: 'unknown' as const, + task_id: '550e8400-e29b-41d4-a716-446655440000', + task_type: 'curate' as const, +} + +describe('TaskFailedSchema', () => { + it('should accept a valid payload', () => { + expect(TaskFailedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept duration_ms=0', () => { + expect(TaskFailedSchema.safeParse({...baseValid, duration_ms: 0}).success).to.equal(true) + }) + + it('should reject negative duration_ms', () => { + expect(TaskFailedSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + }) + + it('should reject unknown task_type', () => { + expect(TaskFailedSchema.safeParse({...baseValid, task_type: 'mystery'}).success).to.equal(false) + }) + + it('should reject empty task_id', () => { + expect(TaskFailedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('should reject error_message field — privacy lock (strict)', () => { + expect(TaskFailedSchema.safeParse({...baseValid, error_message: 'EACCES /home/u/secret.txt'}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(TaskFailedSchema.safeParse({...baseValid, stack: 'at Foo:bar'}).success).to.equal(false) + }) + + it('should reject missing required field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _, ...withoutTaskId} = baseValid + expect(TaskFailedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + describe('failure_kind (M15.6)', () => { + it('accepts every canonical FailureKindValues entry', () => { + for (const kind of FailureKindValues) { + expect(TaskFailedSchema.safeParse({...baseValid, failure_kind: kind}).success).to.equal(true) + } + }) + + it('rejects an out-of-vocabulary failure_kind', () => { + expect(TaskFailedSchema.safeParse({...baseValid, failure_kind: 'oom'}).success).to.equal(false) + }) + + it('rejects missing failure_kind', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {failure_kind: _, ...withoutKind} = baseValid + expect(TaskFailedSchema.safeParse(withoutKind).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts new file mode 100644 index 000000000..6c6973e5a --- /dev/null +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -0,0 +1,244 @@ + +import {expect} from 'chai' +import {z} from 'zod' + +import {ALL_EVENT_SCHEMAS} from '../../../../src/shared/analytics/events/index.js' +import {FORBIDDEN_FIELD_NAMES} from '../../../../src/shared/analytics/forbidden-field-names.js' + +// Sentinel — the test below asserts the imported set still contains the canonical +// names this fixture audits against. Any drift between this fixture and the runtime +// constant would indicate the M11.2 extraction broke privacy coverage. +const FIXTURE_SENTINEL_NAMES: ReadonlySet = new Set([ + // Secrets / credentials + 'access_token', + // PII identifiers (super-properties carry email/name when authenticated; + // event payloads must NEVER repeat them) + 'address', + 'api_key', + // Filesystem paths (M1 spec: "no file paths") + 'argv', + 'auth_header', + 'auth_token', + // User content (M1 spec: "no content of queries, files, or memory") + 'content', + 'cookie', + 'credential', + 'cwd', + 'display_name', + 'email', + // Errors that may carry paths/secrets/content + 'error_message', + 'file_path', + 'first_name', + 'folder_path', + 'goal', + 'home_dir', + // Network identifiers + 'hostname', + 'ip', + 'last_name', + 'mac', + 'output', + 'password', + 'path', + 'phone', + 'phone_number', + 'project_path', + 'prompt', + 'query', + 'result', + 'secret', + 'session_id', + 'session_token', + 'ssn', + 'stack', + 'token', + 'username', + 'worktree_root', +]) + +/** + * Recursively collect every field name reachable from a Zod schema, including + * fields inside nested ZodObject, ZodOptional / ZodNullable wrappers, + * ZodArray element schemas, and ZodUnion / ZodDiscriminatedUnion members. + * The privacy fixture must audit nested shapes because adding + * `{error: {message, code}}` should surface `message` as a forbidden name + * even though the top level only declares `error`. + * + * Discriminated unions (used by `migrate_run` to enforce per-mode counter + * separation) must be walked across every member or a forbidden field + * declared only on the rollback variant would slip past audit. + */ +function getShapeFieldNames(schema: z.ZodTypeAny, seen: Set = new Set()): string[] { + if (seen.has(schema)) return [] + seen.add(schema) + + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + return getShapeFieldNames(schema.unwrap() as z.ZodTypeAny, seen) + } + + if (schema instanceof z.ZodArray) { + return getShapeFieldNames(schema.element as z.ZodTypeAny, seen) + } + + if (schema instanceof z.ZodObject) { + const out: string[] = [] + for (const [key, value] of Object.entries(schema.shape as Record)) { + out.push(key, ...getShapeFieldNames(value, seen)) + } + + return out + } + + if (schema instanceof z.ZodDiscriminatedUnion || schema instanceof z.ZodUnion) { + const out: string[] = [] + for (const option of schema.options as z.ZodTypeAny[]) { + out.push(...getShapeFieldNames(option, seen)) + } + + return out + } + + return [] +} + +describe('analytics privacy fixture (smoke)', () => { + it('should keep the runtime FORBIDDEN_FIELD_NAMES set as a superset of this fixture sentinel', () => { + // Regression guard for the M11.2 extraction: any name this fixture historically + // audited against MUST still be present in the runtime constant. + const missing: string[] = [] + for (const name of FIXTURE_SENTINEL_NAMES) { + if (!FORBIDDEN_FIELD_NAMES.has(name)) missing.push(name) + } + + expect(missing, `runtime FORBIDDEN_FIELD_NAMES dropped: ${missing.join(', ')}`).to.deep.equal([]) + }) + + it('should not declare any field name on the forbidden PII list across all event schemas', () => { + const violations: Array<{eventName: string; field: string}> = [] + + for (const [eventName, schema] of Object.entries(ALL_EVENT_SCHEMAS)) { + for (const field of getShapeFieldNames(schema)) { + if (FORBIDDEN_FIELD_NAMES.has(field)) { + violations.push({eventName, field}) + } + } + } + + expect(violations, `forbidden PII fields detected: ${JSON.stringify(violations)}`).to.deep.equal([]) + }) + + it('should expose every shipped event name under ALL_EVENT_SCHEMAS', () => { + expect(Object.keys(ALL_EVENT_SCHEMAS).sort()).to.deep.equal([ + 'analytics_disabled', + 'auth_login', + 'auth_logout', + 'brv_init', + 'cli_invocation', + 'connector_installed', + 'content_migrated', + 'context_tree_file_edited', + 'curate_operation_applied', + 'curate_run_completed', + 'daemon_reset_executed', + 'daemon_start', + 'hub_package_installed', + 'hub_registry_added', + 'hub_registry_removed', + 'mcp_session_ended', + 'mcp_session_start', + 'mcp_tool_called', + 'migrate_run', + 'onboarding_auto_setup_started', + 'onboarding_completed', + 'query_completed', + 'review_approved', + 'review_rejected', + 'review_toggled', + 'setting_changed', + 'setting_reset', + 'source_added', + 'source_removed', + 'space_switched', + 'swarm_onboarded', + 'swarm_query_completed', + 'swarm_store_completed', + 'task_completed', + 'task_created', + 'task_failed', + 'vc_branched', + 'vc_checked_out', + 'vc_cloned', + 'vc_commit', + 'vc_discarded', + 'vc_fetched', + 'vc_init', + 'vc_merged', + 'vc_pulled', + 'vc_pushed', + 'vc_remote_changed', + 'vc_reset_executed', + 'webui_session_ended', + 'webui_session_started', + 'worktree_added', + 'worktree_removed', + ]) + }) + + describe('walker coverage (regression guard)', () => { + it('should catch a forbidden field name nested inside a ZodObject', () => { + // Synthetic bad schema. If the walker stays at top-level, `email` is missed. + const nestedBad = z.object({ + outer: z.object({ + email: z.string(), + }), + }) + const fields = getShapeFieldNames(nestedBad) + expect(fields).to.include('email') + }) + + it('should catch a forbidden field name inside ZodArray element', () => { + const arrayBad = z.object({ + items: z.array(z.object({password: z.string()})), + }) + const fields = getShapeFieldNames(arrayBad) + expect(fields).to.include('password') + }) + + it('should unwrap ZodOptional and ZodNullable when walking', () => { + const optionalBad = z.object({ + wrapper: z.object({token: z.string()}).optional(), + }) + const nullableBad = z.object({ + // eslint-disable-next-line camelcase + wrapper: z.object({api_key: z.string()}).nullable(), + }) + expect(getShapeFieldNames(optionalBad)).to.include('token') + expect(getShapeFieldNames(nullableBad)).to.include('api_key') + }) + + it('should walk every member of a ZodDiscriminatedUnion', () => { + // Forbidden names live on different variants — the walker MUST visit + // both, or migrate_run-style discriminated schemas would let a PII + // field declared only on one variant slip past privacy audit. + const unionBad = z.discriminatedUnion('kind', [ + z.object({email: z.string(), kind: z.literal('a')}), + // eslint-disable-next-line camelcase + z.object({api_key: z.string(), kind: z.literal('b')}), + ]) + const fields = getShapeFieldNames(unionBad) + expect(fields).to.include('email') + expect(fields).to.include('api_key') + }) + + it('should walk every option of a plain ZodUnion', () => { + const unionBad = z.union([ + z.object({password: z.string()}), + z.object({token: z.string()}), + ]) + const fields = getShapeFieldNames(unionBad) + expect(fields).to.include('password') + expect(fields).to.include('token') + }) + }) +}) diff --git a/test/unit/shared/analytics/redact-record.test.ts b/test/unit/shared/analytics/redact-record.test.ts new file mode 100644 index 000000000..0918fc7c4 --- /dev/null +++ b/test/unit/shared/analytics/redact-record.test.ts @@ -0,0 +1,128 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {StoredAnalyticsRecord} from '../../../../src/shared/analytics/stored-record.js' + +import {FORBIDDEN_FIELD_NAMES, redactRecord} from '../../../../src/shared/analytics/forbidden-field-names.js' + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: 'rec-1', + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +describe('redactRecord (M11.2)', () => { + describe('FORBIDDEN_FIELD_NAMES exports', () => { + it('should export a non-empty Set of forbidden names', () => { + expect(FORBIDDEN_FIELD_NAMES).to.be.instanceOf(Set) + expect(FORBIDDEN_FIELD_NAMES.size).to.be.greaterThan(0) + }) + + it('should include canonical secret/credential names', () => { + for (const name of ['password', 'token', 'access_token', 'secret', 'cookie']) { + expect(FORBIDDEN_FIELD_NAMES.has(name), `forbidden list must include "${name}"`).to.equal(true) + } + }) + + it('should include canonical PII / path names that the M2.8 fixture forbids in event schemas', () => { + for (const name of ['email', 'phone', 'cwd', 'path', 'home_dir']) { + expect(FORBIDDEN_FIELD_NAMES.has(name)).to.equal(true) + } + }) + }) + + describe('redaction over record.properties', () => { + it('should drop forbidden keys from properties (top level)', () => { + const record = makeRecord({ + properties: {command_id: 'status', password: 'p455w0rd', token: 'jwt-xxx'}, + }) + + const out = redactRecord(record) + + expect(out.properties).to.not.have.property('password') + expect(out.properties).to.not.have.property('token') + expect(out.properties).to.have.property('command_id', 'status') + }) + + it('should preserve non-forbidden keys verbatim', () => { + const record = makeRecord({ + properties: {command_id: 'status', duration_ms: 42, success: true}, + }) + + const out = redactRecord(record) + + expect(out.properties).to.deep.equal({command_id: 'status', duration_ms: 42, success: true}) + }) + + it('should leave the record untouched when properties are empty', () => { + const record = makeRecord({properties: {}}) + + const out = redactRecord(record) + + expect(out.properties).to.deep.equal({}) + }) + + it('should NOT recurse into nested objects (top-level redaction only)', () => { + // The forbidden-list check applies only to the immediate keys of properties. + // A nested {meta: {password: '...'}} keeps the nested key — defense lives at the + // M2.8 schema layer (which prevents the schema from declaring nested forbidden + // names), and the runtime redactor is intentionally minimal. + const record = makeRecord({ + properties: {meta: {nested_ok: true, password: 'x'}, password: 'top-level'}, + }) + + const out = redactRecord(record) + + expect(out.properties).to.not.have.property('password') + expect(out.properties.meta).to.deep.equal({nested_ok: true, password: 'x'}) + }) + + it('should return a fresh object (caller-safe — does not mutate input)', () => { + const record = makeRecord({ + properties: {command_id: 'status', password: 'leak'}, + }) + + const out = redactRecord(record) + + expect(out).to.not.equal(record) + expect(out.properties).to.not.equal(record.properties) + // Input properties unchanged. + expect(record.properties).to.have.property('password') + }) + }) + + describe('identity is intentionally NOT redacted (locked decision)', () => { + it('should preserve identity.email even though "email" is on FORBIDDEN_FIELD_NAMES', () => { + const record = makeRecord({ + identity: {device_id: validIdentity.device_id, email: 'alice@example.com'}, + }) + + const out = redactRecord(record) + + expect(out.identity).to.deep.equal({device_id: validIdentity.device_id, email: 'alice@example.com'}) + }) + + it('should preserve identity.name and identity.user_id', () => { + const record = makeRecord({ + identity: {device_id: validIdentity.device_id, name: 'Alice', user_id: 'user-1'}, + }) + + const out = redactRecord(record) + + expect(out.identity).to.deep.equal({ + device_id: validIdentity.device_id, + name: 'Alice', + user_id: 'user-1', + }) + }) + }) +}) diff --git a/test/unit/shared/analytics/stored-record.test.ts b/test/unit/shared/analytics/stored-record.test.ts new file mode 100644 index 000000000..64757f4e1 --- /dev/null +++ b/test/unit/shared/analytics/stored-record.test.ts @@ -0,0 +1,328 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema, toWireEvent} from '../../../../src/shared/analytics/stored-record.js' + +const validIdentity = { + device_id: '550e8400-e29b-41d4-a716-446655440000', +} + +// Both fields describe the same instant. `created_at` is the wire-bound +// ISO 8601 string with offset; `timestamp` is the local-only sort key. +// `1_700_000_000_000` epoch ms = 2023-11-14T22:13:20Z. +const validRecord = { + attempts: 0, + created_at: '2023-11-14T22:13:20+00:00', + id: '11111111-2222-3333-4444-555555555555', + identity: validIdentity, + name: 'cli_invocation', + properties: {x: 1}, + status: 'pending' as const, + timestamp: 1_700_000_000_000, +} + +describe('StoredAnalyticsRecord', () => { + describe('MAX_ATTEMPTS', () => { + it('should export the cap as 3', () => { + expect(MAX_ATTEMPTS).to.equal(3) + }) + }) + + describe('StoredAnalyticsRecordSchema', () => { + it('should accept a valid record', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse(validRecord) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data.id).to.equal(validRecord.id) + expect(parsed.data.status).to.equal('pending') + expect(parsed.data.attempts).to.equal(0) + expect(parsed.data.name).to.equal('cli_invocation') + } + }) + + it('should accept all three status values', () => { + for (const status of ['pending', 'sent', 'failed'] as const) { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, status}) + expect(parsed.success, `status=${status} should parse`).to.equal(true) + } + }) + + it('should reject a record missing id', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with empty id', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, id: ''}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with unknown status', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, status: 'unknown'}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with negative attempts', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, attempts: -1}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with fractional attempts', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, attempts: 1.5}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing identity.device_id', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + identity: {device_id: ''}, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with non-object properties', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + properties: 'not-an-object', + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing name', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing timestamp', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + created_at: validRecord.created_at, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should accept a post-upgrade row carrying both timestamp and created_at', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse(validRecord) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data.timestamp).to.equal(1_700_000_000_000) + expect(parsed.data.created_at).to.equal('2023-11-14T22:13:20+00:00') + } + }) + + it('should accept a pre-upgrade row missing created_at (optional)', () => { + const preUpgrade = { + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + } + const parsed = StoredAnalyticsRecordSchema.safeParse(preUpgrade) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data.created_at).to.equal(undefined) + expect(parsed.data.timestamp).to.equal(1_700_000_000_000) + } + }) + + it('should reject a record where created_at is not ISO 8601 with offset', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + created_at: '2023-11-14', + }) + + expect(parsed.success).to.equal(false) + }) + + it('should accept created_at with Z suffix', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + created_at: '2023-11-14T22:13:20.000Z', + }) + + expect(parsed.success).to.equal(true) + }) + + it('should reject a record missing properties', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing attempts', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing status', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing identity', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should silently strip extra unknown fields (Zod default behavior, matches batch.ts precedent)', () => { + // Use Zod default strip (NOT `.strict()` or `.passthrough()`). Mirrors batch.ts wire + // schemas: strip is forward-compatible — a future binary that adds a new known field, + // reading rows written by the old binary, will not crash. Cost: if a row on disk has + // unknown extra fields, the M9.2 read-modify-rewrite cycle will lose them. + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + unknown_extra_field: 'should be stripped', + }) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data).to.not.have.property('unknown_extra_field') + expect(parsed.data.id).to.equal(validRecord.id) + } + }) + }) + + describe('toWireEvent()', () => { + it('should produce exactly {identity, name, properties, created_at} for a post-upgrade record', () => { + const wire = toWireEvent(validRecord) + + expect(wire).to.deep.equal({ + created_at: '2023-11-14T22:13:20+00:00', + identity: validIdentity, + name: 'cli_invocation', + properties: {x: 1}, + }) + expect(Object.keys(wire).sort()).to.deep.equal(['created_at', 'identity', 'name', 'properties']) + }) + + it('should never emit a numeric timestamp field on the wire', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('timestamp') + }) + + it('should not retain id field', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('id') + }) + + it('should not retain status field', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('status') + }) + + it('should not retain attempts field', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('attempts') + }) + + it('should preserve identity verbatim including optional fields', () => { + const recordWithFullIdentity = { + ...validRecord, + identity: { + device_id: validIdentity.device_id, + email: 'user@example.com', + name: 'Test User', + user_id: 'user-123', + }, + } + const wire = toWireEvent(recordWithFullIdentity) + + expect(wire.identity).to.deep.equal(recordWithFullIdentity.identity) + }) + + it('should pass through created_at verbatim when the stored record carries it', () => { + const wire = toWireEvent({...validRecord, created_at: '2025-02-01T03:04:05+07:00'}) + expect(wire.created_at).to.equal('2025-02-01T03:04:05+07:00') + }) + + it('should derive created_at from the numeric timestamp for pre-upgrade rows', () => { + // Pre-upgrade row: numeric timestamp only, no created_at on disk. + const preUpgrade = {...validRecord, created_at: undefined} + const wire = toWireEvent(preUpgrade) + + // The derived value must describe the same instant as `timestamp`, + // floored to the second (formatISO drops millis). 1_700_000_000_000 = 2023-11-14T22:13:20Z. + expect(wire.created_at).to.be.a('string') + expect(Date.parse(wire.created_at)).to.equal(1_700_000_000_000) + }) + + it('should strip local fields when chained after Zod parse (sent record with attempts > 0)', () => { + const recordWithStatusSent = {...validRecord, attempts: 2, status: 'sent' as const} + const parsed = StoredAnalyticsRecordSchema.safeParse(recordWithStatusSent) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + const wire = toWireEvent(parsed.data) + expect(wire.name).to.equal('cli_invocation') + expect(wire).to.not.have.property('attempts') + expect(wire).to.not.have.property('status') + expect(wire).to.not.have.property('timestamp') + } + }) + }) +}) diff --git a/test/unit/shared/analytics/task-types.test.ts b/test/unit/shared/analytics/task-types.test.ts new file mode 100644 index 000000000..2118ad5c4 --- /dev/null +++ b/test/unit/shared/analytics/task-types.test.ts @@ -0,0 +1,165 @@ + +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CurateRunCompletedSchema} from '../../../../src/shared/analytics/events/curate-run-completed.js' +import {QueryCompletedSchema} from '../../../../src/shared/analytics/events/query-completed.js' +import {TaskCompletedSchema} from '../../../../src/shared/analytics/events/task-completed.js' +import {TaskCreatedSchema} from '../../../../src/shared/analytics/events/task-created.js' +import {TaskFailedSchema} from '../../../../src/shared/analytics/events/task-failed.js' +import {TASK_TYPE_VALUES, type TaskType, TaskTypes} from '../../../../src/shared/analytics/task-types.js' + +describe('TaskTypes', () => { + it('should expose every v4.0 daemon task type', () => { + expect(Object.keys(TaskTypes).sort()).to.deep.equal([ + 'CURATE', + 'CURATE_FOLDER', + 'CURATE_TOOL_MODE', + 'DREAM', + 'DREAM_FINALIZE', + 'DREAM_SCAN', + 'QUERY', + 'QUERY_TOOL_MODE', + 'SEARCH', + // PR #722 re-review: 'unknown' is the drift sentinel emitted by + // AnalyticsHook.toAnalyticsTaskType when the daemon dispatches a + // type that isn't enumerated above. Lives in the canonical + // vocabulary so the wire-side z.enum check accepts the row. + 'UNKNOWN', + ]) + }) + + it('should map each key to the wire string used by the daemon TaskInfo.type', () => { + expect(TaskTypes.CURATE).to.equal('curate') + expect(TaskTypes.CURATE_FOLDER).to.equal('curate-folder') + expect(TaskTypes.CURATE_TOOL_MODE).to.equal('curate-tool-mode') + expect(TaskTypes.DREAM).to.equal('dream') + expect(TaskTypes.DREAM_FINALIZE).to.equal('dream-finalize') + expect(TaskTypes.DREAM_SCAN).to.equal('dream-scan') + expect(TaskTypes.QUERY).to.equal('query') + expect(TaskTypes.QUERY_TOOL_MODE).to.equal('query-tool-mode') + expect(TaskTypes.SEARCH).to.equal('search') + expect(TaskTypes.UNKNOWN).to.equal('unknown') + }) + + it('should expose TaskType as the union of values', () => { + const sample: TaskType = TaskTypes.CURATE + expect(sample).to.equal('curate') + }) + + describe('TASK_TYPE_VALUES', () => { + it('should contain every TaskTypes value exactly once', () => { + expect([...TASK_TYPE_VALUES].sort()).to.deep.equal(Object.values(TaskTypes).sort()) + }) + + it('should be a runtime tuple usable by z.enum', () => { + // Smoke check: TASK_TYPE_VALUES is intended as the source for + // `z.enum(TASK_TYPE_VALUES)` in per-event schemas. Length must be + // non-zero (zod rejects empty enum tuples). + expect(TASK_TYPE_VALUES.length).to.be.greaterThan(0) + }) + }) + + describe('v4.0 tool-mode types validate through task_* schemas', () => { + const newTypes = [ + TaskTypes.CURATE_TOOL_MODE, + TaskTypes.QUERY_TOOL_MODE, + TaskTypes.DREAM_SCAN, + TaskTypes.DREAM_FINALIZE, + ] as const + + for (const taskType of newTypes) { + it(`TaskCreatedSchema accepts task_type='${taskType}'`, () => { + const parsed = TaskCreatedSchema.parse({ + has_files: false, + has_folder: false, + task_id: 't-1', + task_type: taskType, + }) + expect(parsed.task_type).to.equal(taskType) + }) + + it(`TaskCompletedSchema accepts task_type='${taskType}'`, () => { + const parsed = TaskCompletedSchema.parse({ + duration_ms: 100, + task_id: 't-1', + task_type: taskType, + }) + expect(parsed.task_type).to.equal(taskType) + }) + + it(`TaskFailedSchema accepts task_type='${taskType}'`, () => { + const parsed = TaskFailedSchema.parse({ + duration_ms: 100, + failure_kind: 'unknown' as const, + task_id: 't-1', + task_type: taskType, + }) + expect(parsed.task_type).to.equal(taskType) + }) + } + + it('rejects an unknown task_type on all three task_* schemas', () => { + const bad = { + duration_ms: 0, + has_files: false, + has_folder: false, + task_id: 't-1', + task_type: 'not-a-real-type' as unknown as TaskType, + } + expect(() => TaskCreatedSchema.parse(bad)).to.throw() + expect(() => TaskCompletedSchema.parse(bad)).to.throw() + expect(() => TaskFailedSchema.parse(bad)).to.throw() + }) + }) + + describe('M12 per-flavor schemas accept tool-mode types (M14.2)', () => { + const curatePayload = { + duration_ms: 100, + operations_added: 0, + operations_deleted: 0, + operations_failed: 0, + operations_merged: 0, + operations_updated: 0, + outcome: 'completed' as const, + pending_review_count: 0, + task_id: 't-1', + } + + const queryPayload = { + cache_hit: false, + duration_ms: 100, + matched_doc_count: 0, + outcome: 'completed' as const, + read_doc_count: 0, + read_tool_call_count: 0, + search_call_count: 0, + task_id: 't-1', + } + + it('CurateRunCompletedSchema accepts curate-tool-mode', () => { + const parsed = CurateRunCompletedSchema.parse({ + ...curatePayload, + task_type: TaskTypes.CURATE_TOOL_MODE, + }) + expect(parsed.task_type).to.equal('curate-tool-mode') + }) + + it('CurateRunCompletedSchema still accepts the legacy curate / curate-folder values', () => { + const curate = CurateRunCompletedSchema.parse({...curatePayload, task_type: TaskTypes.CURATE}) + expect(curate.task_type).to.equal('curate') + const folder = CurateRunCompletedSchema.parse({...curatePayload, task_type: TaskTypes.CURATE_FOLDER}) + expect(folder.task_type).to.equal('curate-folder') + }) + + it('QueryCompletedSchema accepts query-tool-mode', () => { + const parsed = QueryCompletedSchema.parse({...queryPayload, task_type: TaskTypes.QUERY_TOOL_MODE}) + expect(parsed.task_type).to.equal('query-tool-mode') + }) + + it('QueryCompletedSchema still accepts the legacy query value', () => { + const parsed = QueryCompletedSchema.parse({...queryPayload, task_type: TaskTypes.QUERY}) + expect(parsed.task_type).to.equal('query') + }) + }) +}) diff --git a/test/unit/shared/assets/analytics-disclosure-content.test.ts b/test/unit/shared/assets/analytics-disclosure-content.test.ts new file mode 100644 index 000000000..4f437628b --- /dev/null +++ b/test/unit/shared/assets/analytics-disclosure-content.test.ts @@ -0,0 +1,36 @@ +import {expect} from 'chai' +import {readFile} from 'node:fs/promises' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {PRIVACY_POLICY_URL} from '../../../../src/shared/constants/privacy.js' + +/** + * Contract test on the analytics disclosure markdown content. + * + * Originally lived in the deleted `test/commands/analytics/enable.test.ts` + * ("6. disclosure markdown contains all required sections"). Preserved + * here because the markdown contract is independent of any specific + * surface that renders it — it pins what PM/legal copy MUST contain. + * + * Section headers are load-bearing per the file's own preamble; a + * future copy edit that accidentally drops one fails here loudly. + */ +describe('analytics-disclosure.md content contract', () => { + it('includes the five required sections plus the privacy policy link', async () => { + const here = dirname(fileURLToPath(import.meta.url)) + const disclosurePath = resolve(here, '../../../../src/shared/assets/analytics-disclosure.md') + const text = await readFile(disclosurePath, 'utf8') + + expect(text, 'what-is-collected section').to.match(/what is collected/i) + expect(text, 'which-surfaces section').to.match(/which surfaces|surfaces are tracked/i) + expect(text, 'where-it-goes section').to.match(/where (it )?goes/i) + expect(text, 'cross-device alias section').to.match(/cross-device|alias/i) + // Pin the new disable instruction to the post-M16.4 surface. A regression + // that re-introduces the deleted `brv analytics disable` command (or any + // other variant) fails here loudly. + expect(text, 'how-to-disable section').to.match(/brv settings set analytics\.share false/i) + expect(text, 'how-to-disable must not reference the deleted command').to.not.match(/brv analytics disable/i) + expect(text, 'privacy policy link').to.include(PRIVACY_POLICY_URL) + }) +}) diff --git a/test/unit/shared/transport/events/analytics-list-schema.test.ts b/test/unit/shared/transport/events/analytics-list-schema.test.ts new file mode 100644 index 000000000..b5f3b7f8b --- /dev/null +++ b/test/unit/shared/transport/events/analytics-list-schema.test.ts @@ -0,0 +1,111 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + AnalyticsEvents, + AnalyticsListRequestSchema, + AnalyticsListResponseSchema, +} from '../../../../../src/shared/transport/events/analytics-events.js' + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeValidRow(overrides: Record = {}): Record { + return { + attempts: 0, + id: 'rec-1', + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +describe('analytics:list transport schema (M11.1)', () => { + describe('event constant', () => { + it('should expose LIST = "analytics:list"', () => { + expect(AnalyticsEvents.LIST).to.equal('analytics:list') + }) + }) + + describe('AnalyticsListRequestSchema', () => { + it('should accept a minimal valid request {offset, limit}', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 50, offset: 0}) + expect(parsed.success, 'minimal request must validate').to.equal(true) + }) + + it('should accept a request with optional eventName + status filters', () => { + const parsed = AnalyticsListRequestSchema.safeParse({ + eventName: 'cli_invocation', + limit: 10, + offset: 0, + status: 'pending', + }) + expect(parsed.success).to.equal(true) + }) + + it('should reject offset < 0', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 10, offset: -1}) + expect(parsed.success).to.equal(false) + }) + + it('should reject limit < 1', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 0, offset: 0}) + expect(parsed.success).to.equal(false) + }) + + it('should reject limit > 200', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 201, offset: 0}) + expect(parsed.success).to.equal(false) + }) + + it('should reject non-integer offset/limit', () => { + expect(AnalyticsListRequestSchema.safeParse({limit: 1.5, offset: 0}).success).to.equal(false) + expect(AnalyticsListRequestSchema.safeParse({limit: 10, offset: 1.5}).success).to.equal(false) + }) + + it('should reject an unknown status value', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 10, offset: 0, status: 'archived'}) + expect(parsed.success).to.equal(false) + }) + + it('should reject when offset is missing', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 10}) + expect(parsed.success).to.equal(false) + }) + + it('should reject when limit is missing', () => { + const parsed = AnalyticsListRequestSchema.safeParse({offset: 0}) + expect(parsed.success).to.equal(false) + }) + }) + + describe('AnalyticsListResponseSchema', () => { + it('should accept a response with empty rows + total=0', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [], total: 0}) + expect(parsed.success).to.equal(true) + }) + + it('should accept a response with one valid row + total=1', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [makeValidRow()], total: 1}) + expect(parsed.success).to.equal(true) + }) + + it('should reject a response when a row is malformed (missing required field)', () => { + const malformed = {...makeValidRow(), id: undefined} + const parsed = AnalyticsListResponseSchema.safeParse({rows: [malformed], total: 1}) + expect(parsed.success).to.equal(false) + }) + + it('should reject a response when total is negative', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [], total: -1}) + expect(parsed.success).to.equal(false) + }) + + it('should reject a response when total is non-integer', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [], total: 1.5}) + expect(parsed.success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/utils/format-analytics-status.test.ts b/test/unit/shared/utils/format-analytics-status.test.ts new file mode 100644 index 000000000..34f4ec98f --- /dev/null +++ b/test/unit/shared/utils/format-analytics-status.test.ts @@ -0,0 +1,146 @@ +/* eslint-disable camelcase -- legacy `brv analytics status --format json` envelope is snake_case. */ +import {expect} from 'chai' + +import type {AnalyticsStatusResponse} from '../../../../src/shared/transport/events/analytics-events.js' + +import { + formatAnalyticsStatusJson, + formatAnalyticsStatusText, +} from '../../../../src/shared/utils/format-analytics-status.js' + +const PINNED_NOW = 1_700_000_000_000 + +const HEALTHY: AnalyticsStatusResponse = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: PINNED_NOW - 5 * 60_000, + queueDepth: 4, +} + +function formatAt(response: AnalyticsStatusResponse): string { + return formatAnalyticsStatusText(response, () => PINNED_NOW) +} + +describe('format-analytics-status (M16.3)', () => { + describe('formatAnalyticsStatusText', () => { + const format = formatAt + + it('disabled state: only shows "Analytics: disabled" (other fields suppressed)', () => { + const text = format({...HEALTHY, enabled: false}) + expect(text).to.equal('Analytics: disabled') + }) + + it('enabled, never flushed: "Last successful flush: never"', () => { + const text = format({...HEALTHY, lastFlushAt: undefined}) + expect(text).to.include('Analytics: enabled') + expect(text).to.include('Last successful flush: never') + }) + + it('enabled, flushed 5 minutes ago: ISO timestamp with relative time', () => { + const text = format(HEALTHY) + expect(text).to.include('Last successful flush:') + expect(text).to.include('2023-11-14T22:08:20') + expect(text).to.include('(5m ago)') + }) + + it('"just now" for sub-minute deltas', () => { + const text = format({...HEALTHY, lastFlushAt: PINNED_NOW - 30_000}) + expect(text).to.include('(just now)') + }) + + it('hours-then-days relative formatting', () => { + expect(format({...HEALTHY, lastFlushAt: PINNED_NOW - 3 * 60 * 60_000})).to.include('(3h ago)') + expect(format({...HEALTHY, lastFlushAt: PINNED_NOW - 2 * 24 * 60 * 60_000})).to.include('(2d ago)') + }) + + it('backoff state "degraded": label + consecutive failures + humanized delay', () => { + const text = format({ + ...HEALTHY, + backoff: {consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}, + }) + expect(text).to.include('Backoff state: degraded') + expect(text).to.include('2 consecutive failures') + expect(text).to.include('next attempt in 2m') + }) + + it('singularises "1 consecutive failure" on a single-failure backoff', () => { + const text = format({ + ...HEALTHY, + backoff: {consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}, + }) + expect(text).to.include('1 consecutive failure') + expect(text).to.not.include('1 consecutive failures') + expect(text).to.include('next attempt in 1m') + }) + + it('endpoint not configured: shows literal placeholder + unreachable backoff', () => { + const text = format({ + ...HEALTHY, + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'unreachable'}, + endpoint: '(not configured)', + }) + expect(text).to.include('Endpoint: (not configured)') + expect(text).to.include('Backoff state: unreachable') + }) + + it('renders the distinct rate_limited backoff state (M5.4 — ENG-2658), not unreachable', () => { + const text = format({ + ...HEALTHY, + // 429/503 throttle: zero failures but a server-supplied delay. + backoff: {consecutiveFailures: 0, nextDelayMs: 120_000, state: 'rate_limited'}, + }) + expect(text).to.include('Backoff state: rate_limited') + expect(text).to.not.include('unreachable') + expect(text).to.include('next attempt in 2m') + }) + + it('shows queue depth and dropped events on enabled state', () => { + const text = format({...HEALTHY, droppedCount: 7, queueDepth: 12}) + expect(text).to.include('Queue depth: 12 events') + expect(text).to.include('Dropped events (this session): 7') + }) + + it('returns the unavailable placeholder when value is not a valid snapshot shape', () => { + const noValue: {value?: unknown} = {} + expect(formatAnalyticsStatusText(noValue.value)).to.equal('(unavailable)') + expect(formatAnalyticsStatusText({garbage: true})).to.equal('(unavailable)') + expect(formatAnalyticsStatusText(null)).to.equal('(unavailable)') + }) + }) + + describe('formatAnalyticsStatusJson', () => { + it('emits the legacy snake_case envelope on enabled state', () => { + const flushAt = PINNED_NOW - 5 * 60_000 + const json = formatAnalyticsStatusJson({...HEALTHY, lastFlushAt: flushAt}) + expect(json).to.deep.equal({ + backoff: { + consecutive_failures: 0, + next_delay_ms: 30_000, + state: 'healthy', + }, + dropped_events: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + last_flush: new Date(flushAt).toISOString(), + queue_depth: 4, + }) + }) + + it('last_flush is null when undefined', () => { + const json = formatAnalyticsStatusJson({...HEALTHY, lastFlushAt: undefined}) + if ('unavailable' in json) { + expect.fail('expected a valid JSON shape') + return + } + + expect(json.last_flush).to.equal(null) + }) + + it('returns the unavailable shape when value is not a valid snapshot', () => { + const json = formatAnalyticsStatusJson({garbage: true}) + expect(json).to.deep.equal({unavailable: true}) + }) + }) +}) diff --git a/test/unit/shared/utils/format-settings.test.ts b/test/unit/shared/utils/format-settings.test.ts index 8c9ea961e..896acc5bd 100644 --- a/test/unit/shared/utils/format-settings.test.ts +++ b/test/unit/shared/utils/format-settings.test.ts @@ -3,6 +3,11 @@ import {expect} from 'chai' import type {SettingsItemDTO} from '../../../../src/shared/transport/events/settings-events.js' import type {SettingsRow} from '../../../../src/shared/types/settings-row.js' +import { + formatReadonlyInfoValue, + registerReadonlyInfoFormatter, + unregisterReadonlyInfoFormatter, +} from '../../../../src/shared/utils/format-readonly-info.js' import {buildSettingsRows, parseRowInput} from '../../../../src/shared/utils/format-settings.js' function makeItem(overrides: Partial = {}): SettingsItemDTO { @@ -32,6 +37,21 @@ function makeBooleanItem(current: boolean): SettingsItemDTO { } } +function makeReadonlyInfoItem(current: SettingsItemDTO['current']): SettingsItemDTO { + return { + category: 'updates', + current, + description: 'live analytics snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } +} + +const FIRST_FORMATTER = (): string => 'first' +const SECOND_FORMATTER = (): string => 'second' +const SAME_FORMATTER = (): string => 'same' + function makeRow(overrides: Partial = {}): SettingsRow { return { category: 'concurrency', @@ -126,6 +146,11 @@ describe('format-settings (shared)', () => { expect(rows[0].category).to.equal('other') expect(rows[0].unit).to.equal('count') }) + + it('maps the analytics category onto the row instead of falling back to other', () => { + const rows = buildSettingsRows([makeItem({category: 'analytics', key: 'analytics.share'})]) + expect(rows[0].category).to.equal('analytics') + }) }) describe('parseRowInput', () => { @@ -253,4 +278,123 @@ describe('format-settings (shared)', () => { expect(rows.map((r) => r.category)).to.deep.equal(['concurrency', 'task-history', 'updates']) }) }) + + describe('readonly-info rows (M16.1)', () => { + it('builds a row for readonly-info items', () => { + const rows = buildSettingsRows([makeReadonlyInfoItem({endpoint: 'host', queueDepth: 3})]) + expect(rows).to.have.lengthOf(1) + expect(rows[0].key).to.equal('_test.snapshot') + expect(rows[0].type).to.equal('readonly-info') + }) + + it('renders displayCurrent via the per-key formatter when registered', () => { + registerReadonlyInfoFormatter('_test.snapshot', (value) => { + const v = value as {queueDepth: number} + return `queue=${v.queueDepth}` + }) + try { + const row = buildSettingsRows([makeReadonlyInfoItem({queueDepth: 7})])[0] + expect(row.displayCurrent).to.equal('queue=7') + } finally { + unregisterReadonlyInfoFormatter('_test.snapshot') + } + }) + + it('falls back to JSON.stringify when no per-key formatter is registered', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({queueDepth: 3})])[0] + expect(row.displayCurrent).to.equal('{"queueDepth":3}') + }) + + it('renders "(unavailable)" when current is undefined and no formatter is registered', () => { + const noCurrent: {value?: SettingsItemDTO['current']} = {} + const row = buildSettingsRows([makeReadonlyInfoItem(noCurrent.value)])[0] + expect(row.displayCurrent).to.equal('(unavailable)') + }) + + it('omits displayDefault and displayRange on readonly-info rows', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({q: 1})])[0] + expect(row.displayDefault).to.equal(undefined) + expect(row.displayRange).to.equal('') + expect(row.default).to.equal(undefined) + }) + + it('marks readonly-info rows as not modified (no default to diverge from)', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({q: 1})])[0] + expect(row.modified).to.equal(false) + }) + + it('propagates restartRequired=false onto the row', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({q: 1})])[0] + expect(row.restartRequired).to.equal(false) + }) + + it('mixes correctly with boolean and integer rows; category order preserved', () => { + const rows = buildSettingsRows([ + makeReadonlyInfoItem({q: 1}), + makeItem({category: 'concurrency', key: 'agentPool.maxSize'}), + makeBooleanItem(true), + ]) + // Concurrency comes before updates per CATEGORY_ORDER. Within the + // 'updates' category, the stable sort preserves the input order + // (_test.snapshot at index 0, update.checkForUpdates at index 2). + expect(rows.map((r) => r.key)).to.deep.equal([ + 'agentPool.maxSize', + '_test.snapshot', + 'update.checkForUpdates', + ]) + }) + }) + + describe('formatReadonlyInfoValue (M16.1)', () => { + afterEach(() => { + unregisterReadonlyInfoFormatter('_test.fmt') + }) + + it('returns (unavailable) when value is undefined', () => { + const noValue: {value?: unknown} = {} + expect(formatReadonlyInfoValue('_test.fmt', noValue.value)).to.equal('(unavailable)') + }) + + it('returns the raw string when value is a string and no formatter is registered', () => { + // Strings are not part of the wire type but the formatter survives + // them via JSON.stringify; this guarantees no crash on hand-injected + // values from a future provider that returns a primitive. + expect(formatReadonlyInfoValue('_test.fmt', 'hello')).to.equal('hello') + }) + + it('JSON-stringifies an object payload by default', () => { + expect(formatReadonlyInfoValue('_test.fmt', {a: 1, b: 2})).to.equal('{"a":1,"b":2}') + }) + + it('uses the registered formatter when present', () => { + registerReadonlyInfoFormatter('_test.fmt', (value) => `<${JSON.stringify(value)}>`) + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('<{"x":1}>') + }) + + it('does NOT call the registered formatter for a different key', () => { + registerReadonlyInfoFormatter('_test.fmt', () => 'should-not-fire') + expect(formatReadonlyInfoValue('_test.other', {x: 1})).to.equal('{"x":1}') + }) + + it('unregisterReadonlyInfoFormatter clears the registration', () => { + registerReadonlyInfoFormatter('_test.fmt', () => 'registered') + unregisterReadonlyInfoFormatter('_test.fmt') + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('{"x":1}') + }) + + it('throws when a key is registered twice with a different function (prevents silent override)', () => { + registerReadonlyInfoFormatter('_test.fmt', FIRST_FORMATTER) + expect(() => registerReadonlyInfoFormatter('_test.fmt', SECOND_FORMATTER)).to.throw( + /already registered/i, + ) + // First registration is preserved. + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('first') + }) + + it('is idempotent when the same function is registered twice', () => { + registerReadonlyInfoFormatter('_test.fmt', SAME_FORMATTER) + expect(() => registerReadonlyInfoFormatter('_test.fmt', SAME_FORMATTER)).to.not.throw() + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('same') + }) + }) }) diff --git a/test/unit/tui/features/settings/format-settings.test.ts b/test/unit/tui/features/settings/format-settings.test.ts index 36dc8d01b..7587325a5 100644 --- a/test/unit/tui/features/settings/format-settings.test.ts +++ b/test/unit/tui/features/settings/format-settings.test.ts @@ -102,6 +102,22 @@ describe('format-settings (tui)', () => { expect(hint).to.include('Saving') expect(hint).to.include('background') }) + + it('returns a read-only hint in browse mode when the focused row is readonly-info (M16.1)', () => { + const hint = bottomHintFor('browse', '_test.snapshot', 'readonly-info') + expect(hint).to.include('read-only') + expect(hint).to.not.include('Enter edit') + expect(hint).to.not.include('R reset') + }) + + it('keeps the writable browse hint for boolean/integer rows even when focusedRowType is passed', () => { + expect(bottomHintFor('browse', 'agentPool.maxSize', 'integer')).to.equal( + 'Up/Down move | Enter edit | R reset | Esc exit', + ) + expect(bottomHintFor('browse', 'update.checkForUpdates', 'boolean')).to.equal( + 'Up/Down move | Enter edit | R reset | Esc exit', + ) + }) }) describe('preFillBufferFor', () => { diff --git a/test/unit/webui/features/analytics/api/get-global-config.test.ts b/test/unit/webui/features/analytics/api/get-global-config.test.ts new file mode 100644 index 000000000..9e3911ba6 --- /dev/null +++ b/test/unit/webui/features/analytics/api/get-global-config.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {GlobalConfigEvents} from '../../../../../../src/shared/transport/events/global-config-events.js' +import {getGlobalConfig} from '../../../../../../src/webui/features/analytics/api/get-global-config.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('getGlobalConfig', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits globalConfig:get with no payload', async () => { + request.resolves({analytics: false, deviceId: 'dev-1', version: '1'}) + await getGlobalConfig() + expect(request.firstCall.args[0]).to.equal(GlobalConfigEvents.GET) + }) + + it('returns the analytics, deviceId, and version from the daemon response', async () => { + request.resolves({analytics: true, deviceId: 'dev-2', version: '2'}) + const result = await getGlobalConfig() + expect(result).to.deep.equal({analytics: true, deviceId: 'dev-2', version: '2'}) + }) + + it('rejects when the transport is not connected', async () => { + useTransportStore.setState({apiClient: null}) + await getGlobalConfig().then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error.message).to.equal('Not connected'), + ) + }) +}) diff --git a/test/unit/webui/features/analytics/api/set-analytics.test.ts b/test/unit/webui/features/analytics/api/set-analytics.test.ts new file mode 100644 index 000000000..9dd86a2aa --- /dev/null +++ b/test/unit/webui/features/analytics/api/set-analytics.test.ts @@ -0,0 +1,55 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {GlobalConfigEvents} from '../../../../../../src/shared/transport/events/global-config-events.js' +import {setAnalytics} from '../../../../../../src/webui/features/analytics/api/set-analytics.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('setAnalytics', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits globalConfig:setAnalytics with the analytics payload', async () => { + request.resolves({current: true, previous: false}) + await setAnalytics({analytics: true}) + expect(request.firstCall.args[0]).to.equal(GlobalConfigEvents.SET_ANALYTICS) + expect(request.firstCall.args[1]).to.deep.equal({analytics: true}) + }) + + it('forwards false to disable analytics', async () => { + request.resolves({current: false, previous: true}) + await setAnalytics({analytics: false}) + expect(request.firstCall.args[1]).to.deep.equal({analytics: false}) + }) + + it('resolves with the daemon response on success', async () => { + request.resolves({current: true, previous: false}) + const result = await setAnalytics({analytics: true}) + expect(result).to.deep.equal({current: true, previous: false}) + }) + + it('rejects when the transport is not connected', async () => { + useTransportStore.setState({apiClient: null}) + try { + await setAnalytics({analytics: true}) + expect.fail('expected promise to reject') + } catch (error) { + expect((error as Error).message).to.equal('Not connected') + } + }) +}) diff --git a/test/unit/webui/features/analytics/constants.test.ts b/test/unit/webui/features/analytics/constants.test.ts new file mode 100644 index 000000000..2e03bc3a0 --- /dev/null +++ b/test/unit/webui/features/analytics/constants.test.ts @@ -0,0 +1,44 @@ +import {expect} from 'chai' + +import { + ANALYTICS_DISCLOSURE_SECTIONS, + ANALYTICS_PRIVACY_URL, +} from '../../../../../src/webui/features/analytics/constants.js' + +describe('analytics constants', () => { + describe('ANALYTICS_DISCLOSURE_SECTIONS', () => { + it('contains the five required sections in ticket-spec order', () => { + const labels = ANALYTICS_DISCLOSURE_SECTIONS.map((s) => s.label) + expect(labels).to.deep.equal([ + 'WHAT IS COLLECTED', + 'WHICH SURFACES ARE TRACKED', + 'WHERE IT GOES', + 'CROSS-DEVICE ALIAS', + 'HOW TO DISABLE', + ]) + }) + + it('every section has a non-empty body', () => { + for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { + expect(section.body.length).to.be.greaterThan(0) + } + }) + + it('every section has an icon component reference', () => { + for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { + expect(section.icon).to.exist + expect(['function', 'object']).to.include(typeof section.icon) + } + }) + }) + + describe('ANALYTICS_PRIVACY_URL', () => { + it('is a https URL', () => { + expect(ANALYTICS_PRIVACY_URL).to.match(/^https:\/\//) + }) + + it('points at the byterover privacy docs', () => { + expect(ANALYTICS_PRIVACY_URL).to.include('byterover.dev/privacy') + }) + }) +})