diff --git a/apps/api/package.json b/apps/api/package.json index d7d6ce7..d1bf2e4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,6 +2,9 @@ "name": "@agent-worth/api", "version": "0.1.0", "type": "module", + "imports": { + "#*": "./src/*" + }, "exports": { ".": "./src/app.ts" }, diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index bf03d44..f3efa82 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { syntheticTranscriptEvents } from '@agent-worth/shared'; -import { createApp } from './app'; -import { createMemoryRepository } from './repository'; +import { createApp } from '#app.ts'; +import { createMemoryRepository } from '#repositories/agent-worth.repository.ts'; describe('Agent Worth API', () => { test('enrolls a daemon client with the development token', async () => { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 03c20ee..6b5f383 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,30 +1,35 @@ -import { CloudEventSchema } from '@agent-worth/shared'; import { cors } from '@elysiajs/cors'; import { swagger } from '@elysiajs/swagger'; -import { Elysia, t } from 'elysia'; -import { createMemoryRepository, type Repository, type SessionView } from './repository'; +import { Elysia } from 'elysia'; +import { configuredMaxRequestBodySize } from '#lib/env.ts'; +import { elysiaErrorHandler } from '#lib/errors.ts'; +import { requestResponsePlugin } from '#lib/request-response.ts'; +import { + type AgentWorthRepositoryContract, + createMemoryRepository, +} from '#repositories/agent-worth.repository.ts'; +import { createCostsController } from '#routes/costs/controller.ts'; +import { createEmployeeSummaryController } from '#routes/employees/summary/controller.ts'; +import { createEnrollController } from '#routes/enroll/controller.ts'; +import { createHealthController } from '#routes/health/controller.ts'; +import { createIngestBatchController } from '#routes/ingest/batch/controller.ts'; +import { createSessionsController } from '#routes/sessions/controller.ts'; +import { createServicePlugins } from '#services/plugins.ts'; -const DEFAULT_MAX_REQUEST_BODY_SIZE = 512 * 1024 * 1024; +export function createApp(repository: AgentWorthRepositoryContract = createMemoryRepository()) { + const servicePlugins = createServicePlugins(repository); -function configuredMaxRequestBodySize() { - const parsed = Number(Bun.env.AGENT_WORTH_MAX_REQUEST_BODY_SIZE ?? DEFAULT_MAX_REQUEST_BODY_SIZE); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_REQUEST_BODY_SIZE; -} - -function sessionListItem({ messages: _messages, ...session }: SessionView) { - return session; -} - -export function createApp(repository: Repository = createMemoryRepository()) { return new Elysia({ serve: { maxRequestBodySize: configuredMaxRequestBodySize(), }, }) + .onError(elysiaErrorHandler) .onParse(({ contentType, request }) => { if (contentType === 'application/cloudevents-batch+json') return request.json(); }) .use(cors()) + .use(requestResponsePlugin) .use( swagger({ documentation: { @@ -35,62 +40,12 @@ export function createApp(repository: Repository = createMemoryRepository()) { }, }), ) - .decorate('repository', repository) - .get('/health', () => ({ ok: true })) - .post('/v1/enroll', async ({ body, repository: repo }) => repo.enroll(body), { - body: t.Object({ - enrollmentToken: t.String(), - clientId: t.String(), - hostnameHash: t.Optional(t.String()), - }), - }) - .post( - '/v1/ingest/batch', - async ({ body, headers, repository: repo }) => { - const events = body.map((event) => CloudEventSchema.parse(event)); - return repo.ingestBatch(events, headers.authorization); - }, - { - body: t.Array(t.Any()), - }, - ) - .get( - '/v1/sessions', - ({ query, repository: repo }) => - repo - .listSessions({ - employeeId: query.employeeId, - sourceTool: query.sourceTool, - day: query.day, - usageStatus: query.usageStatus, - }) - .map(sessionListItem), - { - query: t.Object({ - employeeId: t.Optional(t.String()), - sourceTool: t.Optional(t.String()), - day: t.Optional(t.String()), - usageStatus: t.Optional(t.String()), - }), - }, - ) - .get( - '/v1/costs', - ({ query, repository: repo }) => - repo.costSummary({ - day: query.day, - employeeId: query.employeeId, - }), - { - query: t.Object({ - day: t.Optional(t.String()), - employeeId: t.Optional(t.String()), - }), - }, - ) - .get('/v1/employees/:id/summary', ({ params, repository: repo }) => - repo.employeeSummary(params.id), - ); + .use(createHealthController(servicePlugins)) + .use(createEnrollController(servicePlugins)) + .use(createIngestBatchController(servicePlugins)) + .use(createSessionsController(servicePlugins)) + .use(createCostsController(servicePlugins)) + .use(createEmployeeSummaryController(servicePlugins)); } export type App = ReturnType; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index eb0eee6..b7574cb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,9 @@ -import { createApp } from './app'; +import { createApp } from '#app.ts'; +import { createLogger } from '#lib/logger.ts'; const port = Number(Bun.env.PORT ?? 3001); +const logger = createLogger('agent-worth'); createApp().listen(port, ({ hostname, port: actualPort }) => { - console.log(`agent-worth api listening at http://${hostname}:${actualPort}`); + logger.info(`api listening at http://${hostname}:${actualPort}`); }); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts new file mode 100644 index 0000000..b7607c5 --- /dev/null +++ b/apps/api/src/lib/env.ts @@ -0,0 +1,6 @@ +const DEFAULT_MAX_REQUEST_BODY_SIZE = 512 * 1024 * 1024; + +export function configuredMaxRequestBodySize() { + const parsed = Number(Bun.env.AGENT_WORTH_MAX_REQUEST_BODY_SIZE ?? DEFAULT_MAX_REQUEST_BODY_SIZE); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_REQUEST_BODY_SIZE; +} diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..91d44ae --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,49 @@ +import { type ErrorHandler, StatusMap } from 'elysia'; +import { createLogger } from '#lib/logger.ts'; + +const errorLogger = createLogger(); + +export class AppError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + } +} + +export class BadRequestError extends AppError { + constructor(message = 'Bad Request') { + super(StatusMap['Bad Request'], message); + this.name = 'BadRequestError'; + } +} + +export class UnauthorizedError extends AppError { + constructor(message = 'Unauthorized') { + super(StatusMap.Unauthorized, message); + this.name = 'UnauthorizedError'; + } +} + +type ErrorHandlerOptions = Parameters[0]; +type ErrorHandlerResult = ReturnType; + +export function elysiaErrorHandler({ + error, + code, + status, +}: ErrorHandlerOptions): ErrorHandlerResult { + errorLogger.error(code, error); + if (error instanceof AppError) { + return status(error.statusCode, { error: error.message }); + } + if (code === 'VALIDATION') { + return status(StatusMap['Bad Request'], { error: 'Validation error', details: error.message }); + } + if (code === 'NOT_FOUND') { + return status(StatusMap['Not Found'], { error: 'Not Found' }); + } + return status(StatusMap['Internal Server Error'], { error: 'Internal server error' }); +} diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts new file mode 100644 index 0000000..89e033e --- /dev/null +++ b/apps/api/src/lib/logger.ts @@ -0,0 +1,37 @@ +const RESET = '\x1b[0m'; +const LABEL_WIDTH = 5; + +const LEVEL_STYLES: Record = { + debug: '\x1b[36m', + info: '\x1b[32m', + warn: '\x1b[33m', + error: '\x1b[31m', +}; + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +function write(level: LogLevel, prefix: string, ...args: unknown[]): void { + const ts = new Date().toISOString(); + const style = LEVEL_STYLES[level] ?? ''; + const tag = `${style}${level.toUpperCase().padEnd(LABEL_WIDTH)}${RESET}`; + const out = level === 'error' ? console.error : console.log; + const head = prefix ? `${ts} ${tag} [${prefix}]` : `${ts} ${tag}`; + out(head, ...args); +} + +export function createLogger(prefix = '') { + return { + debug(...args: unknown[]) { + write('debug', prefix, ...args); + }, + info(...args: unknown[]) { + write('info', prefix, ...args); + }, + warn(...args: unknown[]) { + write('warn', prefix, ...args); + }, + error(...args: unknown[]) { + write('error', prefix, ...args); + }, + }; +} diff --git a/apps/api/src/lib/request-response.ts b/apps/api/src/lib/request-response.ts new file mode 100644 index 0000000..e66d856 --- /dev/null +++ b/apps/api/src/lib/request-response.ts @@ -0,0 +1,28 @@ +import { Elysia, StatusMap } from 'elysia'; +import { createLogger } from '#lib/logger.ts'; + +const httpLogger = createLogger('http'); + +function formatRequest(request: Request): string { + return `${request.method} ${new URL(request.url).pathname}`; +} + +function resolveStatus(status: number | keyof StatusMap | undefined): number { + return typeof status === 'string' ? StatusMap[status] : (status ?? StatusMap.OK); +} + +function formatElapsed(startedAt: number | undefined): string { + return startedAt === undefined ? '' : ` (${Math.round(performance.now() - startedAt)}ms)`; +} + +export const requestResponsePlugin = new Elysia({ name: 'request-response' }) + .derive(() => ({ requestStartedAt: performance.now() })) + .onRequest(({ request }) => { + httpLogger.info(`-> ${formatRequest(request)}`); + }) + .onAfterResponse(({ request, set, requestStartedAt }) => { + const status = resolveStatus(set.status); + const elapsed = formatElapsed(requestStartedAt); + httpLogger.info(`<- ${formatRequest(request)} ${status}${elapsed}`); + }) + .as('global'); diff --git a/apps/api/src/repositories/agent-worth.repository.ts b/apps/api/src/repositories/agent-worth.repository.ts new file mode 100644 index 0000000..f7436f5 --- /dev/null +++ b/apps/api/src/repositories/agent-worth.repository.ts @@ -0,0 +1,402 @@ +import { seedEmployees } from '@agent-worth/db'; +import { + calculateSessionCost, + hasNativeUsage, + type ModelPrice, + type SourceTool, + selectCurrentPrice, + syntheticModelPrices, + syntheticTranscriptEvents, + type TranscriptCloudEvent, + type TranscriptMessage, + type UsageStatus, +} from '@agent-worth/shared'; +import { Repository } from '#repositories/repository.ts'; + +export type Employee = { + id: string; + displayName: string; + email?: string; + team?: string; +}; + +type Client = { + id: string; + employeeId: string; + apiToken: string; + hostnameHash?: string | undefined; +}; + +type RawArtifact = { + id: string; + employeeId: string; + clientId: string; + sourceTool: SourceTool; + sourceSessionId: string; + sourcePathHash: string; + currentContentHash: string; + versions: RawArtifactVersion[]; +}; + +type RawArtifactVersion = { + id: string; + artifactId: string; + contentHash: string; + storageUri: string; + byteSize: number; + capturedAt: string; +}; + +export type SessionView = { + id: string; + employeeId: string; + employeeName: string; + team?: string; + sourceTool: SourceTool; + sourceSessionId: string; + title?: string; + model?: string; + provider?: string; + startedAt?: string; + endedAt?: string; + usageStatus: UsageStatus; + totalTokens: number; + totalUsd: number; + inputUsd: number; + outputUsd: number; + messages: TranscriptMessage[]; + goalSummary: string | null; + proficiencyScore: number | null; +}; + +export type SessionFilters = { + employeeId?: string | undefined; + sourceTool?: string | undefined; + day?: string | undefined; + usageStatus?: string | undefined; +}; + +export type CostSummaryFilters = { + employeeId?: string | undefined; + day?: string | undefined; +}; + +export type CostSummary = { + totalUsd: number; + totalTokens: number; + sessions: number; + byEmployee: Array<{ + employeeId: string; + employeeName: string; + totalUsd: number; + totalTokens: number; + sessions: number; + }>; + byDay: Array<{ day: string; totalUsd: number; totalTokens: number; sessions: number }>; +}; + +export type EmployeeSummary = { + employee?: Employee; + totalUsd: number; + totalTokens: number; + sessions: SessionView[]; +}; + +export abstract class AgentWorthRepositoryContract extends Repository { + abstract enroll(input: { + enrollmentToken: string; + clientId: string; + hostnameHash?: string | undefined; + }): Promise<{ + employee: Employee; + clientId: string; + apiToken: string; + }>; + abstract ingestBatch(events: TranscriptCloudEvent[]): Promise<{ + accepted: number; + createdVersions: number; + skippedUnchanged: number; + }>; + abstract listSessions(filters?: SessionFilters): SessionView[]; + abstract costSummary(filters?: CostSummaryFilters): CostSummary; + abstract employeeSummary(employeeId: string): EmployeeSummary; +} + +function id(prefix: string): string { + return `${prefix}_${crypto.randomUUID()}`; +} + +function inferProvider(model: string | undefined, sourceTool: SourceTool): string | undefined { + if (!model) { + if (sourceTool === 'codex') return 'openai'; + if (sourceTool.startsWith('claude')) return 'anthropic'; + return undefined; + } + + if (model.includes('claude')) return 'anthropic'; + if (model.includes('gpt') || model.includes('codex')) return 'openai'; + return undefined; +} + +function dayOf(value: string | undefined): string { + return (value ?? new Date().toISOString()).slice(0, 10); +} + +function byteSize(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +export class MemoryAgentWorthRepository extends AgentWorthRepositoryContract { + private readonly employees = new Map( + seedEmployees.map((employee) => [employee.id, employee]), + ); + private readonly clients = new Map(); + private readonly artifacts = new Map(); + private readonly sessions = new Map(); + private readonly prices: ModelPrice[]; + + constructor(options?: { seed?: boolean; modelPrices?: ModelPrice[] }) { + super(); + this.prices = options?.modelPrices ?? syntheticModelPrices; + + this.clients.set('client_synthetic_laptop', { + id: 'client_synthetic_laptop', + employeeId: 'emp_synthetic_ada', + apiToken: 'synthetic-api-token', + }); + this.clients.set('client_synthetic_workstation', { + id: 'client_synthetic_workstation', + employeeId: 'emp_synthetic_grace', + apiToken: 'synthetic-api-token', + }); + + if (options?.seed !== false) { + for (const event of syntheticTranscriptEvents) { + this.upsertEvent(event); + } + } + } + + async enroll(input: { + enrollmentToken: string; + clientId: string; + hostnameHash?: string | undefined; + }): Promise<{ + employee: Employee; + clientId: string; + apiToken: string; + }> { + const employee = this.employees.get('emp_synthetic_ada') ?? { + id: 'emp_synthetic_ada', + displayName: 'Ada Lovelace', + email: 'ada@example.invalid', + team: 'Platform', + }; + + const apiToken = `awt_${crypto.randomUUID().replaceAll('-', '')}`; + this.clients.set(input.clientId, { + id: input.clientId, + employeeId: employee.id, + apiToken, + hostnameHash: input.hostnameHash, + }); + + return { + employee, + clientId: input.clientId, + apiToken, + }; + } + + async ingestBatch(events: TranscriptCloudEvent[]): Promise<{ + accepted: number; + createdVersions: number; + skippedUnchanged: number; + }> { + let createdVersions = 0; + let skippedUnchanged = 0; + + for (const event of events) { + const result = this.upsertEvent(event); + if (result === 'created') createdVersions += 1; + if (result === 'unchanged') skippedUnchanged += 1; + } + + return { + accepted: events.length, + createdVersions, + skippedUnchanged, + }; + } + + listSessions(filters: SessionFilters = {}): SessionView[] { + return [...this.sessions.values()] + .filter((session) => !filters.employeeId || session.employeeId === filters.employeeId) + .filter((session) => !filters.sourceTool || session.sourceTool === filters.sourceTool) + .filter((session) => !filters.usageStatus || session.usageStatus === filters.usageStatus) + .filter((session) => !filters.day || dayOf(session.startedAt) === filters.day) + .sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? '')); + } + + costSummary(filters: CostSummaryFilters = {}): CostSummary { + const filtered = this.listSessions(filters); + const byEmployee = new Map< + string, + { + employeeId: string; + employeeName: string; + totalUsd: number; + totalTokens: number; + sessions: number; + } + >(); + const byDay = new Map< + string, + { day: string; totalUsd: number; totalTokens: number; sessions: number } + >(); + + for (const session of filtered) { + const employeeRow = byEmployee.get(session.employeeId) ?? { + employeeId: session.employeeId, + employeeName: session.employeeName, + totalUsd: 0, + totalTokens: 0, + sessions: 0, + }; + employeeRow.totalUsd += session.totalUsd; + employeeRow.totalTokens += session.totalTokens; + employeeRow.sessions += 1; + byEmployee.set(session.employeeId, employeeRow); + + const day = dayOf(session.startedAt); + const dayRow = byDay.get(day) ?? { day, totalUsd: 0, totalTokens: 0, sessions: 0 }; + dayRow.totalUsd += session.totalUsd; + dayRow.totalTokens += session.totalTokens; + dayRow.sessions += 1; + byDay.set(day, dayRow); + } + + return { + totalUsd: Number(filtered.reduce((sum, session) => sum + session.totalUsd, 0).toFixed(6)), + totalTokens: filtered.reduce((sum, session) => sum + session.totalTokens, 0), + sessions: filtered.length, + byEmployee: [...byEmployee.values()].map((row) => ({ + ...row, + totalUsd: Number(row.totalUsd.toFixed(6)), + })), + byDay: [...byDay.values()].map((row) => ({ + ...row, + totalUsd: Number(row.totalUsd.toFixed(6)), + })), + }; + } + + employeeSummary(employeeId: string): EmployeeSummary { + const filtered = this.listSessions({ employeeId }); + const employee = this.employees.get(employeeId); + return { + ...(employee ? { employee } : {}), + totalUsd: Number(filtered.reduce((sum, session) => sum + session.totalUsd, 0).toFixed(6)), + totalTokens: filtered.reduce((sum, session) => sum + session.totalTokens, 0), + sessions: filtered, + }; + } + + private upsertEvent(event: TranscriptCloudEvent): 'created' | 'unchanged' { + const employeeId = event.employeeid ?? 'emp_synthetic_ada'; + const clientId = event.clientid ?? 'client_synthetic_laptop'; + const payload = event.data; + const artifactKey = `${employeeId}:${payload.source}:${payload.sourceSessionId}:${payload.sourcePathHash}`; + const existing = this.artifacts.get(artifactKey); + + if (existing?.currentContentHash === payload.contentHash) { + return 'unchanged'; + } + + const artifact: RawArtifact = existing ?? { + id: id('artifact'), + employeeId, + clientId, + sourceTool: payload.source, + sourceSessionId: payload.sourceSessionId, + sourcePathHash: payload.sourcePathHash, + currentContentHash: payload.contentHash, + versions: [], + }; + + const version: RawArtifactVersion = { + id: id('artifact_version'), + artifactId: artifact.id, + contentHash: payload.contentHash, + storageUri: `memory://${artifact.id}/${payload.contentHash}`, + byteSize: byteSize(payload.raw), + capturedAt: payload.capturedAt, + }; + + artifact.currentContentHash = payload.contentHash; + artifact.versions.push(version); + this.artifacts.set(artifactKey, artifact); + + const employee = this.employees.get(employeeId) ?? { + id: employeeId, + displayName: employeeId, + }; + this.employees.set(employee.id, employee); + + const provider = inferProvider(payload.model, payload.source); + const price = + provider && payload.model + ? selectCurrentPrice(this.prices, provider, payload.model, payload.capturedAt) + : undefined; + const cost = calculateSessionCost({ + usage: payload.usage, + price, + usageStatus: hasNativeUsage(payload.usage) ? 'native' : 'missing', + }); + const startedAt = + payload.messages.find((message) => message.createdAt)?.createdAt ?? payload.capturedAt; + const endedAt = + [...payload.messages].reverse().find((message) => message.createdAt)?.createdAt ?? + payload.capturedAt; + const totalTokens = + payload.usage?.totalTokens ?? + (payload.usage + ? payload.usage.inputTokens + + payload.usage.outputTokens + + payload.usage.cachedInputTokens + + payload.usage.cacheCreationInputTokens + + payload.usage.reasoningOutputTokens + : 0); + + this.sessions.set(version.id, { + id: version.id, + employeeId, + employeeName: employee.displayName, + sourceTool: payload.source, + sourceSessionId: payload.sourceSessionId, + startedAt, + endedAt, + usageStatus: cost.usageStatus, + totalTokens, + totalUsd: cost.totalUsd, + inputUsd: cost.inputUsd, + outputUsd: cost.outputUsd, + messages: payload.messages, + goalSummary: null, + proficiencyScore: null, + ...(employee.team !== undefined ? { team: employee.team } : {}), + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.model !== undefined ? { model: payload.model } : {}), + ...(provider !== undefined ? { provider } : {}), + }); + + return 'created'; + } +} + +export function createMemoryRepository(options?: { + seed?: boolean; + modelPrices?: ModelPrice[]; +}): AgentWorthRepositoryContract { + return new MemoryAgentWorthRepository(options); +} diff --git a/apps/api/src/repositories/repository.ts b/apps/api/src/repositories/repository.ts new file mode 100644 index 0000000..ee2ca23 --- /dev/null +++ b/apps/api/src/repositories/repository.ts @@ -0,0 +1,5 @@ +import { createLogger } from '#lib/logger.ts'; + +export abstract class Repository { + protected readonly logger = createLogger(this.constructor.name); +} diff --git a/apps/api/src/repository.ts b/apps/api/src/repository.ts deleted file mode 100644 index 3f89285..0000000 --- a/apps/api/src/repository.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { seedEmployees } from '@agent-worth/db'; -import { - calculateSessionCost, - hasNativeUsage, - type ModelPrice, - type SourceTool, - selectCurrentPrice, - syntheticModelPrices, - syntheticTranscriptEvents, - type TranscriptCloudEvent, - type TranscriptMessage, - type UsageStatus, -} from '@agent-worth/shared'; - -type Employee = { - id: string; - displayName: string; - email?: string | undefined; - team?: string | undefined; -}; - -type Client = { - id: string; - employeeId: string; - apiToken: string; - hostnameHash?: string | undefined; -}; - -type RawArtifact = { - id: string; - employeeId: string; - clientId: string; - sourceTool: SourceTool; - sourceSessionId: string; - sourcePathHash: string; - currentContentHash: string; - versions: RawArtifactVersion[]; -}; - -type RawArtifactVersion = { - id: string; - artifactId: string; - contentHash: string; - storageUri: string; - byteSize: number; - capturedAt: string; -}; - -export type SessionView = { - id: string; - employeeId: string; - employeeName: string; - team?: string | undefined; - sourceTool: SourceTool; - sourceSessionId: string; - title?: string | undefined; - model?: string | undefined; - provider?: string | undefined; - startedAt?: string | undefined; - endedAt?: string | undefined; - usageStatus: UsageStatus; - totalTokens: number; - totalUsd: number; - inputUsd: number; - outputUsd: number; - messages: TranscriptMessage[]; - goalSummary: string | null; - proficiencyScore: number | null; -}; - -export type Repository = { - enroll(input: { enrollmentToken: string; clientId: string; hostnameHash?: string }): Promise<{ - employee: Employee; - clientId: string; - apiToken: string; - }>; - ingestBatch( - events: TranscriptCloudEvent[], - authorization?: string, - ): Promise<{ - accepted: number; - createdVersions: number; - skippedUnchanged: number; - }>; - listSessions(filters?: { - employeeId?: string | undefined; - sourceTool?: string | undefined; - day?: string | undefined; - usageStatus?: string | undefined; - }): SessionView[]; - costSummary(filters?: { employeeId?: string | undefined; day?: string | undefined }): { - totalUsd: number; - totalTokens: number; - sessions: number; - byEmployee: Array<{ - employeeId: string; - employeeName: string; - totalUsd: number; - totalTokens: number; - sessions: number; - }>; - byDay: Array<{ day: string; totalUsd: number; totalTokens: number; sessions: number }>; - }; - employeeSummary(employeeId: string): { - employee: Employee | undefined; - totalUsd: number; - totalTokens: number; - sessions: SessionView[]; - }; -}; - -function id(prefix: string): string { - return `${prefix}_${crypto.randomUUID()}`; -} - -function inferProvider(model: string | undefined, sourceTool: SourceTool): string | undefined { - if (!model) { - if (sourceTool === 'codex') return 'openai'; - if (sourceTool.startsWith('claude')) return 'anthropic'; - return undefined; - } - - if (model.includes('claude')) return 'anthropic'; - if (model.includes('gpt') || model.includes('codex')) return 'openai'; - return undefined; -} - -function dayOf(value: string | undefined): string { - return (value ?? new Date().toISOString()).slice(0, 10); -} - -function byteSize(value: unknown): number { - return new TextEncoder().encode(JSON.stringify(value)).byteLength; -} - -export function createMemoryRepository(options?: { - seed?: boolean; - modelPrices?: ModelPrice[]; -}): Repository { - const employees = new Map( - seedEmployees.map((employee) => [employee.id, employee]), - ); - const clients = new Map(); - const artifacts = new Map(); - const sessions = new Map(); - const prices = options?.modelPrices ?? syntheticModelPrices; - - clients.set('client_synthetic_laptop', { - id: 'client_synthetic_laptop', - employeeId: 'emp_synthetic_ada', - apiToken: 'synthetic-api-token', - }); - clients.set('client_synthetic_workstation', { - id: 'client_synthetic_workstation', - employeeId: 'emp_synthetic_grace', - apiToken: 'synthetic-api-token', - }); - - function upsertEvent(event: TranscriptCloudEvent): 'created' | 'unchanged' { - const employeeId = event.employeeid ?? 'emp_synthetic_ada'; - const clientId = event.clientid ?? 'client_synthetic_laptop'; - const payload = event.data; - const artifactKey = `${employeeId}:${payload.source}:${payload.sourceSessionId}:${payload.sourcePathHash}`; - const existing = artifacts.get(artifactKey); - - if (existing?.currentContentHash === payload.contentHash) { - return 'unchanged'; - } - - const artifact: RawArtifact = existing ?? { - id: id('artifact'), - employeeId, - clientId, - sourceTool: payload.source, - sourceSessionId: payload.sourceSessionId, - sourcePathHash: payload.sourcePathHash, - currentContentHash: payload.contentHash, - versions: [], - }; - - const version: RawArtifactVersion = { - id: id('artifact_version'), - artifactId: artifact.id, - contentHash: payload.contentHash, - storageUri: `memory://${artifact.id}/${payload.contentHash}`, - byteSize: byteSize(payload.raw), - capturedAt: payload.capturedAt, - }; - - artifact.currentContentHash = payload.contentHash; - artifact.versions.push(version); - artifacts.set(artifactKey, artifact); - - const employee = employees.get(employeeId) ?? { - id: employeeId, - displayName: employeeId, - }; - employees.set(employee.id, employee); - - const provider = inferProvider(payload.model, payload.source); - const price = - provider && payload.model - ? selectCurrentPrice(prices, provider, payload.model, payload.capturedAt) - : undefined; - const cost = calculateSessionCost({ - usage: payload.usage, - price, - usageStatus: hasNativeUsage(payload.usage) ? 'native' : 'missing', - }); - const startedAt = - payload.messages.find((message) => message.createdAt)?.createdAt ?? payload.capturedAt; - const endedAt = - [...payload.messages].reverse().find((message) => message.createdAt)?.createdAt ?? - payload.capturedAt; - const totalTokens = - payload.usage?.totalTokens ?? - (payload.usage - ? payload.usage.inputTokens + - payload.usage.outputTokens + - payload.usage.cachedInputTokens + - payload.usage.cacheCreationInputTokens + - payload.usage.reasoningOutputTokens - : 0); - - sessions.set(version.id, { - id: version.id, - employeeId, - employeeName: employee.displayName, - team: employee.team, - sourceTool: payload.source, - sourceSessionId: payload.sourceSessionId, - title: payload.title, - model: payload.model, - provider, - startedAt, - endedAt, - usageStatus: cost.usageStatus, - totalTokens, - totalUsd: cost.totalUsd, - inputUsd: cost.inputUsd, - outputUsd: cost.outputUsd, - messages: payload.messages, - goalSummary: null, - proficiencyScore: null, - }); - - return 'created'; - } - - if (options?.seed !== false) { - for (const event of syntheticTranscriptEvents) { - upsertEvent(event); - } - } - - return { - async enroll(input) { - const employee = employees.get('emp_synthetic_ada') ?? { - id: 'emp_synthetic_ada', - displayName: 'Ada Lovelace', - email: 'ada@example.invalid', - team: 'Platform', - }; - - if (input.enrollmentToken !== (Bun.env.AGENT_WORTH_ENROLLMENT_TOKEN ?? 'dev-enroll-token')) { - throw new Response('Invalid enrollment token', { status: 401 }); - } - - const apiToken = `awt_${crypto.randomUUID().replaceAll('-', '')}`; - clients.set(input.clientId, { - id: input.clientId, - employeeId: employee.id, - apiToken, - hostnameHash: input.hostnameHash, - }); - - return { - employee, - clientId: input.clientId, - apiToken, - }; - }, - async ingestBatch(events, authorization) { - if (authorization && !authorization.startsWith('Bearer ')) { - throw new Response('Unsupported authorization header', { status: 401 }); - } - - let createdVersions = 0; - let skippedUnchanged = 0; - - for (const event of events) { - const result = upsertEvent(event); - if (result === 'created') createdVersions += 1; - if (result === 'unchanged') skippedUnchanged += 1; - } - - return { - accepted: events.length, - createdVersions, - skippedUnchanged, - }; - }, - listSessions(filters = {}) { - return [...sessions.values()] - .filter((session) => !filters.employeeId || session.employeeId === filters.employeeId) - .filter((session) => !filters.sourceTool || session.sourceTool === filters.sourceTool) - .filter((session) => !filters.usageStatus || session.usageStatus === filters.usageStatus) - .filter((session) => !filters.day || dayOf(session.startedAt) === filters.day) - .sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? '')); - }, - costSummary(filters = {}) { - const filtered = this.listSessions(filters); - const byEmployee = new Map< - string, - { - employeeId: string; - employeeName: string; - totalUsd: number; - totalTokens: number; - sessions: number; - } - >(); - const byDay = new Map< - string, - { day: string; totalUsd: number; totalTokens: number; sessions: number } - >(); - - for (const session of filtered) { - const employeeRow = byEmployee.get(session.employeeId) ?? { - employeeId: session.employeeId, - employeeName: session.employeeName, - totalUsd: 0, - totalTokens: 0, - sessions: 0, - }; - employeeRow.totalUsd += session.totalUsd; - employeeRow.totalTokens += session.totalTokens; - employeeRow.sessions += 1; - byEmployee.set(session.employeeId, employeeRow); - - const day = dayOf(session.startedAt); - const dayRow = byDay.get(day) ?? { day, totalUsd: 0, totalTokens: 0, sessions: 0 }; - dayRow.totalUsd += session.totalUsd; - dayRow.totalTokens += session.totalTokens; - dayRow.sessions += 1; - byDay.set(day, dayRow); - } - - return { - totalUsd: Number(filtered.reduce((sum, session) => sum + session.totalUsd, 0).toFixed(6)), - totalTokens: filtered.reduce((sum, session) => sum + session.totalTokens, 0), - sessions: filtered.length, - byEmployee: [...byEmployee.values()].map((row) => ({ - ...row, - totalUsd: Number(row.totalUsd.toFixed(6)), - })), - byDay: [...byDay.values()].map((row) => ({ - ...row, - totalUsd: Number(row.totalUsd.toFixed(6)), - })), - }; - }, - employeeSummary(employeeId) { - const filtered = this.listSessions({ employeeId }); - return { - employee: employees.get(employeeId), - totalUsd: Number(filtered.reduce((sum, session) => sum + session.totalUsd, 0).toFixed(6)), - totalTokens: filtered.reduce((sum, session) => sum + session.totalTokens, 0), - sessions: filtered, - }; - }, - }; -} diff --git a/apps/api/src/routes/costs/controller.ts b/apps/api/src/routes/costs/controller.ts new file mode 100644 index 0000000..b498331 --- /dev/null +++ b/apps/api/src/routes/costs/controller.ts @@ -0,0 +1,26 @@ +import { Elysia, StatusMap } from 'elysia'; +import { CostSummaryQuerySchema, CostSummaryResponseSchema } from '#routes/costs/model.ts'; +import type { ServicePlugins } from '#services/plugins.ts'; + +export function createCostsController({ CostsServicePlugin, loggerPlugin }: ServicePlugins) { + return new Elysia() + .use(loggerPlugin('costsController')) + .use(CostsServicePlugin) + .get( + '/v1/costs', + ({ query, costsService, logger, status }) => { + logger.info(`summarizing costs employee=${query.employeeId ?? ''} day=${query.day ?? ''}`); + const result = costsService.summarize({ + day: query.day, + employeeId: query.employeeId, + }); + return status(StatusMap.OK, result); + }, + { + query: CostSummaryQuerySchema, + response: { + [StatusMap.OK]: CostSummaryResponseSchema, + }, + }, + ); +} diff --git a/apps/api/src/routes/costs/model.ts b/apps/api/src/routes/costs/model.ts new file mode 100644 index 0000000..03cffe8 --- /dev/null +++ b/apps/api/src/routes/costs/model.ts @@ -0,0 +1,29 @@ +import { t } from 'elysia'; + +export const CostSummaryQuerySchema = t.Object({ + day: t.Optional(t.String()), + employeeId: t.Optional(t.String()), +}); + +export const CostSummaryEmployeeSchema = t.Object({ + employeeId: t.String(), + employeeName: t.String(), + totalUsd: t.Number(), + totalTokens: t.Number(), + sessions: t.Integer(), +}); + +export const CostSummaryDaySchema = t.Object({ + day: t.String(), + totalUsd: t.Number(), + totalTokens: t.Number(), + sessions: t.Integer(), +}); + +export const CostSummaryResponseSchema = t.Object({ + totalUsd: t.Number(), + totalTokens: t.Number(), + sessions: t.Integer(), + byEmployee: t.Array(CostSummaryEmployeeSchema), + byDay: t.Array(CostSummaryDaySchema), +}); diff --git a/apps/api/src/routes/employees/summary/controller.ts b/apps/api/src/routes/employees/summary/controller.ts new file mode 100644 index 0000000..c9b1b39 --- /dev/null +++ b/apps/api/src/routes/employees/summary/controller.ts @@ -0,0 +1,29 @@ +import { Elysia, StatusMap } from 'elysia'; +import { + EmployeeSummaryParamsSchema, + EmployeeSummaryResponseSchema, +} from '#routes/employees/summary/model.ts'; +import type { ServicePlugins } from '#services/plugins.ts'; + +export function createEmployeeSummaryController({ + EmployeesServicePlugin, + loggerPlugin, +}: ServicePlugins) { + return new Elysia() + .use(loggerPlugin('employeeSummaryController')) + .use(EmployeesServicePlugin) + .get( + '/v1/employees/:id/summary', + ({ params, employeesService, logger, status }) => { + logger.info(`summarizing employee ${params.id}`); + const result = employeesService.summarize(params.id); + return status(StatusMap.OK, result); + }, + { + params: EmployeeSummaryParamsSchema, + response: { + [StatusMap.OK]: EmployeeSummaryResponseSchema, + }, + }, + ); +} diff --git a/apps/api/src/routes/employees/summary/model.ts b/apps/api/src/routes/employees/summary/model.ts new file mode 100644 index 0000000..7df9866 --- /dev/null +++ b/apps/api/src/routes/employees/summary/model.ts @@ -0,0 +1,14 @@ +import { t } from 'elysia'; +import { EmployeeSchema } from '#routes/enroll/model.ts'; +import { SessionViewSchema } from '#routes/sessions/model.ts'; + +export const EmployeeSummaryParamsSchema = t.Object({ + id: t.String(), +}); + +export const EmployeeSummaryResponseSchema = t.Object({ + employee: t.Optional(EmployeeSchema), + totalUsd: t.Number(), + totalTokens: t.Number(), + sessions: t.Array(SessionViewSchema), +}); diff --git a/apps/api/src/routes/enroll/controller.ts b/apps/api/src/routes/enroll/controller.ts new file mode 100644 index 0000000..47403d7 --- /dev/null +++ b/apps/api/src/routes/enroll/controller.ts @@ -0,0 +1,23 @@ +import { Elysia, StatusMap } from 'elysia'; +import { EnrollBodySchema, EnrollResponseSchema } from '#routes/enroll/model.ts'; +import type { ServicePlugins } from '#services/plugins.ts'; + +export function createEnrollController({ EnrollmentServicePlugin, loggerPlugin }: ServicePlugins) { + return new Elysia() + .use(loggerPlugin('enrollController')) + .use(EnrollmentServicePlugin) + .post( + '/v1/enroll', + async ({ body, enrollmentService, logger, status }) => { + logger.info(`enrolling client ${body.clientId}`); + const result = await enrollmentService.enroll(body); + return status(StatusMap.OK, result); + }, + { + body: EnrollBodySchema, + response: { + [StatusMap.OK]: EnrollResponseSchema, + }, + }, + ); +} diff --git a/apps/api/src/routes/enroll/model.ts b/apps/api/src/routes/enroll/model.ts new file mode 100644 index 0000000..1c68df3 --- /dev/null +++ b/apps/api/src/routes/enroll/model.ts @@ -0,0 +1,20 @@ +import { t } from 'elysia'; + +export const EnrollBodySchema = t.Object({ + enrollmentToken: t.String(), + clientId: t.String(), + hostnameHash: t.Optional(t.String()), +}); + +export const EmployeeSchema = t.Object({ + id: t.String(), + displayName: t.String(), + email: t.Optional(t.String()), + team: t.Optional(t.String()), +}); + +export const EnrollResponseSchema = t.Object({ + employee: EmployeeSchema, + clientId: t.String(), + apiToken: t.String(), +}); diff --git a/apps/api/src/routes/health/controller.ts b/apps/api/src/routes/health/controller.ts new file mode 100644 index 0000000..5b0223c --- /dev/null +++ b/apps/api/src/routes/health/controller.ts @@ -0,0 +1,22 @@ +import { Elysia, StatusMap } from 'elysia'; +import { GetHealthResponseSchema } from '#routes/health/model.ts'; +import type { ServicePlugins } from '#services/plugins.ts'; + +export function createHealthController({ HealthServicePlugin, loggerPlugin }: ServicePlugins) { + return new Elysia() + .use(loggerPlugin('healthController')) + .use(HealthServicePlugin) + .get( + '/health', + async ({ healthService, logger, status }) => { + logger.info('handling health check request'); + const result = await healthService.check(); + return status(StatusMap.OK, result); + }, + { + response: { + [StatusMap.OK]: GetHealthResponseSchema, + }, + }, + ); +} diff --git a/apps/api/src/routes/health/model.ts b/apps/api/src/routes/health/model.ts new file mode 100644 index 0000000..90af95d --- /dev/null +++ b/apps/api/src/routes/health/model.ts @@ -0,0 +1,5 @@ +import { t } from 'elysia'; + +export const GetHealthResponseSchema = t.Object({ + ok: t.Literal(true), +}); diff --git a/apps/api/src/routes/ingest/batch/controller.ts b/apps/api/src/routes/ingest/batch/controller.ts new file mode 100644 index 0000000..15d1ab6 --- /dev/null +++ b/apps/api/src/routes/ingest/batch/controller.ts @@ -0,0 +1,23 @@ +import { Elysia, StatusMap } from 'elysia'; +import { IngestBatchBodySchema, IngestBatchResponseSchema } from '#routes/ingest/batch/model.ts'; +import type { ServicePlugins } from '#services/plugins.ts'; + +export function createIngestBatchController({ IngestServicePlugin, loggerPlugin }: ServicePlugins) { + return new Elysia() + .use(loggerPlugin('ingestBatchController')) + .use(IngestServicePlugin) + .post( + '/v1/ingest/batch', + async ({ body, headers, ingestService, logger, status }) => { + logger.info(`ingesting ${body.length} CloudEvent(s)`); + const result = await ingestService.ingestBatch(body, headers.authorization); + return status(StatusMap.OK, result); + }, + { + body: IngestBatchBodySchema, + response: { + [StatusMap.OK]: IngestBatchResponseSchema, + }, + }, + ); +} diff --git a/apps/api/src/routes/ingest/batch/model.ts b/apps/api/src/routes/ingest/batch/model.ts new file mode 100644 index 0000000..89ba85d --- /dev/null +++ b/apps/api/src/routes/ingest/batch/model.ts @@ -0,0 +1,9 @@ +import { t } from 'elysia'; + +export const IngestBatchBodySchema = t.Array(t.Any()); + +export const IngestBatchResponseSchema = t.Object({ + accepted: t.Integer(), + createdVersions: t.Integer(), + skippedUnchanged: t.Integer(), +}); diff --git a/apps/api/src/routes/sessions/controller.ts b/apps/api/src/routes/sessions/controller.ts new file mode 100644 index 0000000..10450e1 --- /dev/null +++ b/apps/api/src/routes/sessions/controller.ts @@ -0,0 +1,30 @@ +import { Elysia, StatusMap } from 'elysia'; +import { ListSessionsQuerySchema, ListSessionsResponseSchema } from '#routes/sessions/model.ts'; +import type { ServicePlugins } from '#services/plugins.ts'; + +export function createSessionsController({ SessionsServicePlugin, loggerPlugin }: ServicePlugins) { + return new Elysia() + .use(loggerPlugin('sessionsController')) + .use(SessionsServicePlugin) + .get( + '/v1/sessions', + ({ query, sessionsService, logger, status }) => { + logger.info( + `listing sessions employee=${query.employeeId ?? ''} source=${query.sourceTool ?? ''} day=${query.day ?? ''}`, + ); + const result = sessionsService.listSessions({ + employeeId: query.employeeId, + sourceTool: query.sourceTool, + day: query.day, + usageStatus: query.usageStatus, + }); + return status(StatusMap.OK, result); + }, + { + query: ListSessionsQuerySchema, + response: { + [StatusMap.OK]: ListSessionsResponseSchema, + }, + }, + ); +} diff --git a/apps/api/src/routes/sessions/model.ts b/apps/api/src/routes/sessions/model.ts new file mode 100644 index 0000000..6d0f894 --- /dev/null +++ b/apps/api/src/routes/sessions/model.ts @@ -0,0 +1,53 @@ +import { t } from 'elysia'; + +const NullableStringSchema = t.Union([t.String(), t.Null()]); +const NullableNumberSchema = t.Union([t.Number(), t.Null()]); + +export const SourceToolSchema = t.Union([ + t.Literal('codex'), + t.Literal('claude-code'), + t.Literal('claude-cowork'), +]); + +export const UsageStatusSchema = t.Union([ + t.Literal('native'), + t.Literal('estimated'), + t.Literal('missing'), +]); + +export const ListSessionsQuerySchema = t.Object({ + employeeId: t.Optional(t.String()), + sourceTool: t.Optional(t.String()), + day: t.Optional(t.String()), + usageStatus: t.Optional(t.String()), +}); + +export const SessionListItemSchema = t.Object({ + id: t.String(), + employeeId: t.String(), + employeeName: t.String(), + team: t.Optional(t.String()), + sourceTool: SourceToolSchema, + sourceSessionId: t.String(), + title: t.Optional(t.String()), + model: t.Optional(t.String()), + provider: t.Optional(t.String()), + startedAt: t.Optional(t.String()), + endedAt: t.Optional(t.String()), + usageStatus: UsageStatusSchema, + totalTokens: t.Number(), + totalUsd: t.Number(), + inputUsd: t.Number(), + outputUsd: t.Number(), + goalSummary: NullableStringSchema, + proficiencyScore: NullableNumberSchema, +}); + +export const SessionViewSchema = t.Composite([ + SessionListItemSchema, + t.Object({ + messages: t.Array(t.Any()), + }), +]); + +export const ListSessionsResponseSchema = t.Array(SessionListItemSchema); diff --git a/apps/api/src/services/costs.service.ts b/apps/api/src/services/costs.service.ts new file mode 100644 index 0000000..cb60b22 --- /dev/null +++ b/apps/api/src/services/costs.service.ts @@ -0,0 +1,20 @@ +import type { + AgentWorthRepositoryContract, + CostSummaryFilters, +} from '#repositories/agent-worth.repository.ts'; +import { Service } from '#services/service.ts'; + +export class CostsService extends Service { + private readonly repository: AgentWorthRepositoryContract; + + constructor(repository: AgentWorthRepositoryContract) { + super(); + this.repository = repository; + } + + summarize(filters: CostSummaryFilters = {}) { + const result = this.repository.costSummary(filters); + this.logger.info(`summarized ${result.sessions} session(s)`); + return result; + } +} diff --git a/apps/api/src/services/employees.service.ts b/apps/api/src/services/employees.service.ts new file mode 100644 index 0000000..60156a7 --- /dev/null +++ b/apps/api/src/services/employees.service.ts @@ -0,0 +1,17 @@ +import type { AgentWorthRepositoryContract } from '#repositories/agent-worth.repository.ts'; +import { Service } from '#services/service.ts'; + +export class EmployeesService extends Service { + private readonly repository: AgentWorthRepositoryContract; + + constructor(repository: AgentWorthRepositoryContract) { + super(); + this.repository = repository; + } + + summarize(employeeId: string) { + const result = this.repository.employeeSummary(employeeId); + this.logger.info(`summarized employee ${employeeId}`); + return result; + } +} diff --git a/apps/api/src/services/enrollment.service.ts b/apps/api/src/services/enrollment.service.ts new file mode 100644 index 0000000..306f04f --- /dev/null +++ b/apps/api/src/services/enrollment.service.ts @@ -0,0 +1,28 @@ +import { UnauthorizedError } from '#lib/errors.ts'; +import type { AgentWorthRepositoryContract } from '#repositories/agent-worth.repository.ts'; +import { Service } from '#services/service.ts'; + +export type EnrollInput = { + enrollmentToken: string; + clientId: string; + hostnameHash?: string | undefined; +}; + +export class EnrollmentService extends Service { + private readonly repository: AgentWorthRepositoryContract; + + constructor(repository: AgentWorthRepositoryContract) { + super(); + this.repository = repository; + } + + async enroll(input: EnrollInput) { + if (input.enrollmentToken !== (Bun.env.AGENT_WORTH_ENROLLMENT_TOKEN ?? 'dev-enroll-token')) { + throw new UnauthorizedError('Invalid enrollment token'); + } + + const result = await this.repository.enroll(input); + this.logger.info(`enrolled client ${input.clientId}`); + return result; + } +} diff --git a/apps/api/src/services/health.service.ts b/apps/api/src/services/health.service.ts new file mode 100644 index 0000000..fdddbbf --- /dev/null +++ b/apps/api/src/services/health.service.ts @@ -0,0 +1,8 @@ +import { Service } from '#services/service.ts'; + +export class HealthService extends Service { + async check(): Promise<{ ok: true }> { + this.logger.info('handling health check'); + return { ok: true }; + } +} diff --git a/apps/api/src/services/ingest.service.ts b/apps/api/src/services/ingest.service.ts new file mode 100644 index 0000000..043d0e3 --- /dev/null +++ b/apps/api/src/services/ingest.service.ts @@ -0,0 +1,38 @@ +import { CloudEventSchema, type TranscriptCloudEvent } from '@agent-worth/shared'; +import { z } from 'zod'; +import { BadRequestError, UnauthorizedError } from '#lib/errors.ts'; +import type { AgentWorthRepositoryContract } from '#repositories/agent-worth.repository.ts'; +import { Service } from '#services/service.ts'; + +export class IngestService extends Service { + private readonly repository: AgentWorthRepositoryContract; + + constructor(repository: AgentWorthRepositoryContract) { + super(); + this.repository = repository; + } + + async ingestBatch(events: unknown[], authorization?: string | undefined) { + if (authorization && !authorization.startsWith('Bearer ')) { + throw new UnauthorizedError('Unsupported authorization header'); + } + + const parsed = events.map((event) => parseCloudEvent(event)); + const result = await this.repository.ingestBatch(parsed); + this.logger.info( + `ingested ${result.accepted} event(s): ${result.createdVersions} created, ${result.skippedUnchanged} unchanged`, + ); + return result; + } +} + +function parseCloudEvent(event: unknown): TranscriptCloudEvent { + try { + return CloudEventSchema.parse(event); + } catch (error) { + if (error instanceof z.ZodError) { + throw new BadRequestError(`Invalid CloudEvent payload: ${error.issues[0]?.message ?? ''}`); + } + throw error; + } +} diff --git a/apps/api/src/services/plugins.ts b/apps/api/src/services/plugins.ts new file mode 100644 index 0000000..bd0341d --- /dev/null +++ b/apps/api/src/services/plugins.ts @@ -0,0 +1,58 @@ +import { Elysia } from 'elysia'; +import { createLogger } from '#lib/logger.ts'; +import { + type AgentWorthRepositoryContract, + createMemoryRepository, +} from '#repositories/agent-worth.repository.ts'; +import { CostsService } from '#services/costs.service.ts'; +import { EmployeesService } from '#services/employees.service.ts'; +import { EnrollmentService } from '#services/enrollment.service.ts'; +import { HealthService } from '#services/health.service.ts'; +import { IngestService } from '#services/ingest.service.ts'; +import { SessionsService } from '#services/sessions.service.ts'; + +export function loggerPlugin(name: string) { + const logger = createLogger(name); + return new Elysia({ name: `logger.${name}` }).derive({ as: 'scoped' }, () => ({ logger })); +} + +export function createServicePlugins( + repository: AgentWorthRepositoryContract = createMemoryRepository(), +) { + const healthService = new HealthService(); + const enrollmentService = new EnrollmentService(repository); + const ingestService = new IngestService(repository); + const sessionsService = new SessionsService(repository); + const costsService = new CostsService(repository); + const employeesService = new EmployeesService(repository); + + return { + loggerPlugin, + HealthServicePlugin: new Elysia({ name: 'service.health' }).decorate( + 'healthService', + healthService, + ), + EnrollmentServicePlugin: new Elysia({ name: 'service.enrollment' }).decorate( + 'enrollmentService', + enrollmentService, + ), + IngestServicePlugin: new Elysia({ name: 'service.ingest' }).decorate( + 'ingestService', + ingestService, + ), + SessionsServicePlugin: new Elysia({ name: 'service.sessions' }).decorate( + 'sessionsService', + sessionsService, + ), + CostsServicePlugin: new Elysia({ name: 'service.costs' }).decorate( + 'costsService', + costsService, + ), + EmployeesServicePlugin: new Elysia({ name: 'service.employees' }).decorate( + 'employeesService', + employeesService, + ), + }; +} + +export type ServicePlugins = ReturnType; diff --git a/apps/api/src/services/service.ts b/apps/api/src/services/service.ts new file mode 100644 index 0000000..3193e2f --- /dev/null +++ b/apps/api/src/services/service.ts @@ -0,0 +1,5 @@ +import { createLogger } from '#lib/logger.ts'; + +export abstract class Service { + protected readonly logger = createLogger(this.constructor.name); +} diff --git a/apps/api/src/services/sessions.service.ts b/apps/api/src/services/sessions.service.ts new file mode 100644 index 0000000..3826e39 --- /dev/null +++ b/apps/api/src/services/sessions.service.ts @@ -0,0 +1,63 @@ +import type { + AgentWorthRepositoryContract, + SessionFilters, + SessionView, +} from '#repositories/agent-worth.repository.ts'; +import { Service } from '#services/service.ts'; + +export type SessionListItem = { + id: string; + employeeId: string; + employeeName: string; + team?: string; + sourceTool: SessionView['sourceTool']; + sourceSessionId: string; + title?: string; + model?: string; + provider?: string; + startedAt?: string; + endedAt?: string; + usageStatus: SessionView['usageStatus']; + totalTokens: number; + totalUsd: number; + inputUsd: number; + outputUsd: number; + goalSummary: string | null; + proficiencyScore: number | null; +}; + +export class SessionsService extends Service { + private readonly repository: AgentWorthRepositoryContract; + + constructor(repository: AgentWorthRepositoryContract) { + super(); + this.repository = repository; + } + + listSessions(filters: SessionFilters = {}): SessionListItem[] { + const sessions = this.repository.listSessions(filters).map(sessionListItem); + this.logger.info(`listed ${sessions.length} session(s)`); + return sessions; + } +} + +function sessionListItem({ + messages: _messages, + team, + title, + model, + provider, + startedAt, + endedAt, + ...session +}: SessionView): SessionListItem { + return { + ...session, + ...(team !== undefined ? { team } : {}), + ...(title !== undefined ? { title } : {}), + ...(model !== undefined ? { model } : {}), + ...(provider !== undefined ? { provider } : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + ...(endedAt !== undefined ? { endedAt } : {}), + }; +} diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts new file mode 100644 index 0000000..1a6fb03 --- /dev/null +++ b/apps/api/src/types.ts @@ -0,0 +1,3 @@ +import type { createApp } from '#app.ts'; + +export type App = ReturnType;