diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index b3f70a27d..60f9ca5b9 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -90,6 +90,7 @@ import { SourceHandler, SpaceHandler, StatusHandler, + SwarmHandler, TeamHandler, VcHandler, WorktreeHandler, @@ -567,6 +568,13 @@ export async function setupFeatureHandlers({ 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 diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 69370805b..ede49b5bc 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -9,6 +9,7 @@ import {AuthLogoutSchema} from '../../../../shared/analytics/events/auth-logout. 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' @@ -158,6 +159,13 @@ export class AnalyticsHandler { 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 diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 386b21b01..c842b5192 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -46,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/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/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 2e2495b21..b822295af 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -20,6 +20,7 @@ export const AnalyticsEventNames = { 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', @@ -43,6 +44,9 @@ export const AnalyticsEventNames = { 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', 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/index.ts b/src/shared/analytics/events/index.ts index d63e6843c..1507cfab6 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -7,6 +7,7 @@ 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' @@ -30,6 +31,9 @@ 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' @@ -72,6 +76,7 @@ export const ALL_EVENT_SCHEMAS = { [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, @@ -95,6 +100,9 @@ export const ALL_EVENT_SCHEMAS = { [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, @@ -128,6 +136,7 @@ export type AnyAnalyticsEvent = | {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} @@ -151,6 +160,9 @@ export type AnyAnalyticsEvent = | {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} 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/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/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/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index f207d218f..29232dbef 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -3,7 +3,7 @@ import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' describe('AnalyticsEventNames', () => { - it('should expose exactly the forty-seven shipped event names', () => { + it('should expose exactly the fifty-one shipped event names', () => { expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ 'ANALYTICS_DISABLED', 'AUTH_LOGIN', @@ -11,6 +11,7 @@ describe('AnalyticsEventNames', () => { 'BRV_INIT', 'CLI_INVOCATION', 'CONNECTOR_INSTALLED', + 'CONTENT_MIGRATED', 'CONTEXT_TREE_FILE_EDITED', 'CURATE_OPERATION_APPLIED', 'CURATE_RUN_COMPLETED', @@ -34,6 +35,9 @@ describe('AnalyticsEventNames', () => { 'SOURCE_ADDED', 'SOURCE_REMOVED', 'SPACE_SWITCHED', + 'SWARM_ONBOARDED', + 'SWARM_QUERY_COMPLETED', + 'SWARM_STORE_COMPLETED', 'TASK_COMPLETED', 'TASK_CREATED', 'TASK_FAILED', diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index bca77c5f7..6c6973e5a 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -136,6 +136,7 @@ describe('analytics privacy fixture (smoke)', () => { 'brv_init', 'cli_invocation', 'connector_installed', + 'content_migrated', 'context_tree_file_edited', 'curate_operation_applied', 'curate_run_completed', @@ -159,6 +160,9 @@ describe('analytics privacy fixture (smoke)', () => { 'source_added', 'source_removed', 'space_switched', + 'swarm_onboarded', + 'swarm_query_completed', + 'swarm_store_completed', 'task_completed', 'task_created', 'task_failed',